Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfcadc71fa |
No files matched your search
@@ -47,7 +47,6 @@ next-env.d.ts
|
||||
.superpowers/
|
||||
/.worktrees/
|
||||
/.impeccable/
|
||||
/.serena/
|
||||
|
||||
# generated analysis artifacts
|
||||
/graphify-out/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -4,8 +4,3 @@
|
||||
- `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: software sob medida
|
||||
# Evandro.dev.br: portfólio
|
||||
|
||||
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.
|
||||
Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ Site comercial de Evandro Fonseca Junior para projetos de software sob medida: s
|
||||
- Node.js `22.x`
|
||||
- pnpm `9.15.9`, fixado em `package.json#packageManager`
|
||||
|
||||
O projeto usa Next.js `16.3.3`, patch de segurança que corrige [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) e [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4). A atualização substitui `16.3.0`, afetada por esses avisos. As auditorias de produção e desenvolvimento continuam sendo verificadas na CI.
|
||||
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.
|
||||
|
||||
## Início rápido
|
||||
|
||||
@@ -35,7 +35,7 @@ corepack [email protected] start
|
||||
| `pnpm build` | Gera o build de produção com Turbopack. |
|
||||
| `pnpm start` | Serve um build já gerado. |
|
||||
| `pnpm lint` | Executa `biome check`. |
|
||||
| `pnpm typecheck` | Gera os tipos de rota com `next typegen` e executa TypeScript sem emitir arquivos. |
|
||||
| `pnpm typecheck` | Executa TypeScript sem emitir arquivos. |
|
||||
| `pnpm format` | Formata o repositório com Biome. |
|
||||
| `pnpm test` | Executa a suíte Jest. |
|
||||
| `pnpm test:watch` | Executa Jest em modo watch. |
|
||||
@@ -45,10 +45,10 @@ corepack [email protected] start
|
||||
|
||||
| Área | Pacotes usados |
|
||||
| --- | --- |
|
||||
| Aplicação | Next.js 16.3.3, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
|
||||
| Aplicação | Next.js 16.3.0, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
|
||||
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
|
||||
| Movimento | Motion 12 e GSAP 3. |
|
||||
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
|
||||
| 3D e gráficos | Three.js, React Three Fiber, Drei e Cobe. |
|
||||
| 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 comercial com hero, notebook 3D, projetos, soluções, processo de trabalho, engenharia com IA, critérios de entrega, perfil e contato. |
|
||||
| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato. |
|
||||
| `/cases` | Índice dos seis cases. |
|
||||
| `/cases/nutriscan` | Case NutriScan. |
|
||||
| `/cases/twins-lab` | Case Twins Lab. |
|
||||
@@ -66,19 +66,18 @@ 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` | Página de destino comercial: sites, sistemas e aplicativos, contato direto no WhatsApp, cases, processo e acesso aos seis serviços. |
|
||||
| `/servicos` | Índice dos seis serviços, com explicações de uso e acesso ao detalhamento técnico. |
|
||||
| `/servicos/criacao-de-sites` | Criação de sites. |
|
||||
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
|
||||
| `/servicos/sob-medida` | Redirecionamento permanente (308) para `/servicos/engenharia-de-software`, preservando query string, UTMs e identificadores de clique. |
|
||||
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
|
||||
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto. |
|
||||
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
|
||||
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
|
||||
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
|
||||
|
||||
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.png`, `/apple-icon.png` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
|
||||
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.
|
||||
|
||||
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é.
|
||||
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.
|
||||
|
||||
## Comportamento da aplicação
|
||||
|
||||
@@ -88,53 +87,12 @@ O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts
|
||||
|
||||
Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md).
|
||||
|
||||
### Posicionamento comercial e uso de IA
|
||||
|
||||
A comunicação apresenta Evandro como responsável direto pelo projeto, sem sugerir equipe, disponibilidade garantida, prazo fixo ou certificação. Os cards de projetos reutilizam os desafios e resumos dos cases quando disponíveis. O antigo painel de telemetria fictícia foi removido; os critérios de qualidade não representam notas de auditoria.
|
||||
|
||||
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
|
||||
|
||||
`app/components/EngineeringWithAI.tsx` explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 e links junto de cada afirmação:
|
||||
|
||||
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
|
||||
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
|
||||
- Base44: falha de autenticação identificada pela Wiz, corrigida em menos de 24 horas, sem evidência reportada de exploração maliciosa.
|
||||
|
||||
As obrigações dos pequenos agentes de tratamento são referenciadas à Resolução CD/ANPD nº 2/2022, arts. 6º, 12 e 13. Ao atualizar esse conteúdo, preservar datas, limites dos relatos e a distinção entre vulnerabilidade, exposição e incidente confirmado.
|
||||
|
||||
O formulário valida os campos no navegador e abre um rascunho no WhatsApp para revisão. Ele não envia a mensagem automaticamente e orienta a não informar senhas nem dados pessoais de clientes.
|
||||
|
||||
A seção de contato tem título estático único, canais diretos à esquerda e formulário à direita (empilhados no celular). A silhueta do WhatsApp aparece no contato direto, no cabeçalho do formulário e no botão verde. O formulário não se move no hover; campos mantêm rótulos, validação nativa e foco visível. O link direto dispensa preenchimento e o e-mail usa `mailto:`.
|
||||
|
||||
Na página `/servicos` e nos seis detalhamentos, `WhatsAppLink` oferece um link nativo com mensagem contextual, sem exigir o formulário da home. Funciona mesmo sem consentimento de analytics ou JavaScript; a medição depende da hidratação e da tag. A página principal prioriza as três intenções dos anúncios antes das especialidades técnicas e usa os cases existentes NutriScan e SC Plus como exemplos de trabalho, não como promessa de retorno comercial.
|
||||
|
||||
### 3D progressivo e movimento reduzido
|
||||
|
||||
O hero 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.
|
||||
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.
|
||||
|
||||
As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha.
|
||||
|
||||
Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. Assim que a chegada se resolve, a restauração volta a pertencer ao navegador: Voltar e Avançar restauram a posição do histórico em vez de forçar o topo.
|
||||
|
||||
### Identidade visual
|
||||
|
||||
O header usa `--site-header-height`: 60px abaixo de 1280px e 68px a partir desse breakpoint. A mesma variável define a posição do menu, o respiro inicial do hero e a compensação das âncoras. O menu mobile tem fundo opaco; a barra fica levemente translúcida ao rolar. Header e hero usam Space Grotesk para leitura comercial, reservando a fonte monoespaçada aos detalhes técnicos das demais seções.
|
||||
|
||||
Os quatro arquivos recebidos estão preservados, com nomes descritivos, em `imagens/originais/`. As versões prontas para uso ficam em `public/brand/`:
|
||||
|
||||
| Arquivo | Aplicação |
|
||||
| --- | --- |
|
||||
| `wordmark-dark.webp` | Logo horizontal transparente, branca e lima, no cabeçalho escuro. |
|
||||
| `wordmark-light.webp` | Logo horizontal transparente, escura e lima, no rodapé claro. |
|
||||
| `stacked-light.webp` | Logo empilhada transparente, reservada para composições maiores em fundo claro. |
|
||||
| `mark-dark.svg` / `mark-light.svg` | Símbolo vetorial para fundos escuros e claros, traçado a partir do original limpo. |
|
||||
| `mark-dark.png` | Símbolo de 512px incorporado aos cards Open Graph de todas as rotas. |
|
||||
| `icon-192.png` | Ícone de instalação do manifesto. |
|
||||
|
||||
`app/icon.png` (512px), `app/apple-icon.png` (180px) e `app/favicon.ico` (16–256px) usam o símbolo sobre fundo escuro. As variantes mantêm as formas originais; o recorte transparente com bordas danificadas permanece arquivado, sem uso na interface.
|
||||
|
||||
### Contato
|
||||
|
||||
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail.
|
||||
@@ -143,22 +101,6 @@ 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
|
||||
@@ -177,7 +119,7 @@ nixpacks.toml Build e start para Nixpacks
|
||||
|
||||
## CI e deploy
|
||||
|
||||
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high. O passo de typecheck gera os tipos de rota antes do `tsc`: `next-env.d.ts` não é versionado e, em uma árvore recém-clonada, é ele que declara os módulos de imagem importados pelo código.
|
||||
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high.
|
||||
|
||||
O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório.
|
||||
|
||||
|
||||
@@ -58,12 +58,16 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
function expectCaseCtas() {
|
||||
const destinations = screen
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href"));
|
||||
expect(destinations).toContain("/#contact");
|
||||
expect(destinations).toContain("/servicos");
|
||||
expect(destinations).not.toContain("/curriculo");
|
||||
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");
|
||||
}
|
||||
|
||||
it("keeps the six public params and factual case metadata", async () => {
|
||||
@@ -209,7 +213,7 @@ it("renders the authorized detailed case in order with traceable sources", async
|
||||
});
|
||||
|
||||
it("keeps the fallback concise and links its declared services", async () => {
|
||||
const caseStudy = getCase("sc-plus");
|
||||
const caseStudy = getCase("gcc-installer");
|
||||
expect(caseStudy.detail).toBeUndefined();
|
||||
const ui = await CasePage({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
@@ -222,9 +226,7 @@ it("keeps the fallback concise and links its declared services", async () => {
|
||||
for (const technology of caseStudy.stack) {
|
||||
expect(screen.getByText(technology)).toBeVisible();
|
||||
}
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Abrir demonstração" }),
|
||||
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||
expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
|
||||
"href",
|
||||
caseStudy.githubUrl,
|
||||
|
||||
@@ -35,11 +35,10 @@ export async function generateMetadata({
|
||||
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
return {
|
||||
title: `${caseStudy.title} | Case de Engenharia`,
|
||||
title: `${caseStudy.title} | Case de Engenharia de Software`,
|
||||
description: caseStudy.summary,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
url,
|
||||
@@ -110,14 +109,6 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
|
||||
{caseStudy.detail?.answer.text ?? caseStudy.summary}
|
||||
</p>
|
||||
{caseStudy.role ? (
|
||||
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
|
||||
<strong className="font-semibold text-white">
|
||||
Minha participação:
|
||||
</strong>{" "}
|
||||
{caseStudy.role}
|
||||
</p>
|
||||
) : null}
|
||||
{!caseStudy.detail ? (
|
||||
<p className="mt-sp-6 text-[15px] leading-8 text-white">
|
||||
{caseStudy.impact}
|
||||
@@ -245,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.project.href}
|
||||
href={intentCtas.hiring.href}
|
||||
>
|
||||
{intentCtas.project.label}
|
||||
{intentCtas.hiring.label}
|
||||
</Link>
|
||||
<Link
|
||||
className="ds-link ds-link-muted"
|
||||
|
||||
+2
-3
@@ -18,7 +18,6 @@ export const metadata: Metadata = {
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
@@ -137,11 +136,11 @@ export default function CasesPage() {
|
||||
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={intentCtas.project.href}
|
||||
href={intentCtas.hiring.href}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{intentCtas.project.label}
|
||||
{intentCtas.hiring.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: "Decisões técnicas",
|
||||
label: "Execution",
|
||||
title: "Entrega clara mesmo sob restrição",
|
||||
content:
|
||||
"Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.",
|
||||
"Trabalho bem em contextos com legado, integrações complexas e pouca margem para erro, priorizando estabilidade, leitura técnica e evolução sustentável.",
|
||||
},
|
||||
{
|
||||
label: "Operação",
|
||||
label: "Production mindset",
|
||||
title: "Do código ao sistema no ar",
|
||||
content:
|
||||
"Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.",
|
||||
"Penso além da feature: deploy, observabilidade, manutenção e riscos de operação entram na decisão técnica desde o primeiro corte.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -40,9 +40,9 @@ const PROFILE_POINTS = [
|
||||
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.",
|
||||
},
|
||||
{
|
||||
title: "Responsabilidade técnica",
|
||||
title: "Ownership ponta a ponta",
|
||||
description:
|
||||
"Contato direto com quem define a arquitetura e desenvolve o sistema.",
|
||||
"Responsabilidade que cobre discovery, implementação, qualidade e operação.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -86,14 +86,14 @@ export default function About() {
|
||||
return (
|
||||
<Section
|
||||
id="about"
|
||||
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."
|
||||
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."
|
||||
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" />
|
||||
Base em Garuva, SC | Atendimento remoto
|
||||
Garuva, SC | Remote worldwide
|
||||
</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">Da arquitetura à implementação</div>
|
||||
<div className="ds-kicker">Technical profile</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
|
||||
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
|
||||
<p className="ds-copy" data-reel="top">
|
||||
Meu trabalho conecta interface, serviços, dados e, quando
|
||||
necessário, equipamentos. Entender essas partes em conjunto ajuda a
|
||||
definir o que construir, o que integrar e o que pode ser
|
||||
aproveitado.
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
|
||||
{PROFILE_POINTS.map((point) => (
|
||||
|
||||
+202
-223
@@ -1,252 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { trackWhatsAppOpen } from "@/lib/analytics";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Section } from "./Section";
|
||||
|
||||
export default function Contact() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
const fieldClassName =
|
||||
"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";
|
||||
"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)]";
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "Sistemas web sob medida",
|
||||
subject: "full-stack",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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" });
|
||||
|
||||
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] },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
<Section
|
||||
id="contact"
|
||||
aria-labelledby="contact-title"
|
||||
className="ds-section ds-section-dark"
|
||||
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
|
||||
>
|
||||
<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-8 border-l-2 border-[var(--slash)] pl-sp-5">
|
||||
<h3 className="font-display text-lg font-semibold text-white">
|
||||
Você fala direto com quem desenvolve.
|
||||
</h3>
|
||||
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65">
|
||||
Sou Evandro. A primeira conversa é para entender sua necessidade;
|
||||
escopo, prazo e proposta vêm depois.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
|
||||
<WhatsAppLink
|
||||
service="Contato"
|
||||
placement="contact_direct"
|
||||
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
||||
>
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
|
||||
<WhatsAppMark className="h-6 w-6" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display">
|
||||
<span className="block text-sm font-semibold text-white group-hover:text-[#25d366]">
|
||||
Prefere conversar direto?
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-white/65">
|
||||
WhatsApp · {whatsAppDisplay}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</WhatsAppLink>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 break-all font-display text-sm">
|
||||
evandrofonsecajunior@gmail.com
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className="ml-auto h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-sm leading-6 text-white/55">
|
||||
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
aria-labelledby="contact-form-title"
|
||||
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||
<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="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 className="ds-kicker" data-reel="left">
|
||||
Availability
|
||||
</div>
|
||||
<div className="space-y-sp-5">
|
||||
<div className="grid gap-sp-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="contact-name" className={labelClassName}>
|
||||
Nome completo
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Como posso te chamar?"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-email" className={labelClassName}>
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="[email protected]"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-subject" className={labelClassName}>
|
||||
Tipo de projeto
|
||||
</label>
|
||||
<select
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, subject: e.target.value })
|
||||
}
|
||||
className={`${fieldClassName} [color-scheme:dark]`}
|
||||
>
|
||||
<option value="Sistemas web sob medida">
|
||||
Sistemas web sob medida
|
||||
</option>
|
||||
<option value="Aplicativos">Aplicativos</option>
|
||||
<option value="Integrações e automações">
|
||||
Integrações e automações
|
||||
</option>
|
||||
<option value="Evolução de um sistema existente">
|
||||
Evolução de um sistema existente
|
||||
</option>
|
||||
<option value="Ainda preciso definir">
|
||||
Ainda preciso definir
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-message" className={labelClassName}>
|
||||
Contexto do projeto
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
required
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
||||
aria-describedby="contact-privacy-note"
|
||||
value={formData.message}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, message: e.target.value })
|
||||
}
|
||||
className={`${fieldClassName} min-h-32 resize-y`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 font-display text-base font-semibold leading-6 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
|
||||
>
|
||||
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
||||
Continuar no WhatsApp
|
||||
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
<p
|
||||
id="contact-privacy-note"
|
||||
className="font-display text-xs leading-6 text-white/55"
|
||||
>
|
||||
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
|
||||
para você revisar. Não inclua senhas nem dados pessoais de
|
||||
clientes.
|
||||
</p>
|
||||
<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.
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
className="ds-card space-y-sp-5"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-name"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
Nome completo
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-email"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-subject"
|
||||
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
|
||||
>
|
||||
Tipo de projeto
|
||||
</label>
|
||||
<select
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, subject: e.target.value })
|
||||
}
|
||||
className={fieldClassName}
|
||||
style={{ color: "#fff", backgroundColor: "#1a1a1a" }}
|
||||
>
|
||||
<option value="full-stack">Desenvolvimento Full-Stack</option>
|
||||
<option value="cloud">Cloud & Infrastructure</option>
|
||||
<option value="iot">Internet of Things (IoT)</option>
|
||||
<option value="consultancy">Consultoria 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>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { render, screen } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Footer contact and navigation", () => {
|
||||
it("offers a direct mailto link without repeating the contact call to action", () => {
|
||||
it("renders the contact card and a mailto link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
// o convite principal fica na secao Contato, logo acima do rodape
|
||||
expect(screen.getByText("Contato")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByRole("heading", {
|
||||
name: "Engenharia com menos ruído e mais clareza de execução",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const emailLink = screen.getByRole("link", {
|
||||
name: "[email protected]",
|
||||
@@ -31,32 +33,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 = screen.getAllByRole("link", { name: /profile/i });
|
||||
const socialLinks = Array.from(
|
||||
document.querySelectorAll("footer a[aria-label]"),
|
||||
);
|
||||
|
||||
for (const link of socialLinks) {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
@@ -73,3 +75,16 @@ 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();
|
||||
});
|
||||
});
|
||||
+12
-30
@@ -37,9 +37,7 @@ 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();
|
||||
@@ -52,14 +50,23 @@ export default function Footer() {
|
||||
<div>
|
||||
<div className="ds-kicker">Contato</div>
|
||||
<span className="ds-draw-line" />
|
||||
<div className="mt-sp-5">
|
||||
<h3 className="ds-card-title">
|
||||
Engenharia com menos ruído e mais clareza de execução
|
||||
</h3>
|
||||
<p className="ds-copy max-w-[48ch]">
|
||||
Se você precisa de alguém para conectar produto, integração e
|
||||
entrega com mais critério técnico, este portfólio é o ponto de
|
||||
partida.
|
||||
</p>
|
||||
|
||||
<div className="mt-sp-6">
|
||||
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
|
||||
E-mail direto
|
||||
</p>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="mailto:[email protected]"
|
||||
className="inline-flex min-h-11 items-center break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
|
||||
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
|
||||
strength={0.2}
|
||||
>
|
||||
evandrofonsecajunior@gmail.com
|
||||
@@ -68,13 +75,6 @@ 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,27 +143,9 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8 ds-metric">
|
||||
Software sob medida, contato direto
|
||||
</div>
|
||||
<div className="mt-sp-8 ds-metric">Open to collaboration</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 commands = analyticsWindow.dataLayer?.map((entry) =>
|
||||
Array.from(entry),
|
||||
);
|
||||
expect(commands).toContainEqual([
|
||||
const dataLayer = analyticsWindow.dataLayer;
|
||||
expect(dataLayer).toHaveLength(4);
|
||||
expect(dataLayer?.every((entry) => !Array.isArray(entry))).toBe(true);
|
||||
expect(Array.from(dataLayer?.[0] ?? [])).toEqual([
|
||||
"consent",
|
||||
"default",
|
||||
{
|
||||
@@ -92,7 +92,8 @@ describe("GoogleAnalytics consent", () => {
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(commands).toContainEqual([
|
||||
expect(Array.from(dataLayer?.[1] ?? [])).toEqual(["js", expect.any(Date)]);
|
||||
expect(Array.from(dataLayer?.[2] ?? [])).toEqual([
|
||||
"consent",
|
||||
"update",
|
||||
{
|
||||
@@ -102,7 +103,7 @@ describe("GoogleAnalytics consent", () => {
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(commands).toContainEqual([
|
||||
expect(Array.from(dataLayer?.[3] ?? [])).toEqual([
|
||||
"config",
|
||||
"G-QTT9QT0T3S",
|
||||
{
|
||||
@@ -110,12 +111,6 @@ 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,16 +2,10 @@
|
||||
|
||||
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 GOOGLE_ADS_ID = "AW-18432694647";
|
||||
const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
|
||||
const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
|
||||
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
||||
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
|
||||
const SITE_DOMAIN = "evandro.dev.br";
|
||||
|
||||
@@ -22,6 +16,26 @@ 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);
|
||||
@@ -93,7 +107,6 @@ 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;
|
||||
}
|
||||
@@ -105,10 +118,6 @@ 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(() => {
|
||||
@@ -189,9 +198,8 @@ 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 de navegação com a tag compartilhada
|
||||
do Google Analytics e Google Ads. Cookies de publicidade e
|
||||
personalização continuam desativados.
|
||||
Você decide se permite métricas anônimas para melhorar este site.
|
||||
Publicidade e personalização continuam desativadas.
|
||||
</p>
|
||||
{consentError && (
|
||||
<p
|
||||
@@ -246,10 +254,7 @@ function GoogleAnalyticsConsent() {
|
||||
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
|
||||
onClick={() => setPreferencesOpen((open) => !open)}
|
||||
>
|
||||
<span className="max-sm:hidden">Preferências de analytics</span>
|
||||
<span aria-hidden="true" className="sm:hidden">
|
||||
Cookies
|
||||
</span>
|
||||
Preferências de analytics
|
||||
</button>
|
||||
{preferencesOpen && (
|
||||
<section
|
||||
@@ -271,7 +276,7 @@ function GoogleAnalyticsConsent() {
|
||||
className="mt-sp-2 text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
|
||||
Cookies de publicidade e personalização permanecem negados.
|
||||
Dados de publicidade permanecem negados.
|
||||
</p>
|
||||
{consentError && (
|
||||
<p
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { type ReactNode, 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(() => {
|
||||
@@ -14,38 +38,42 @@ 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("links to commercial destinations without promoting the résumé", () => {
|
||||
it("exposes the primary navigation and the existing CV asset", () => {
|
||||
render(<Header />);
|
||||
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Navegação principal",
|
||||
});
|
||||
expect(
|
||||
within(navigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/#modalities",
|
||||
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#home",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Casos" })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases",
|
||||
"/#process",
|
||||
);
|
||||
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",
|
||||
"/#experience",
|
||||
]);
|
||||
const contactLink = screen
|
||||
.getAllByRole("link")
|
||||
.find((link) => link.getAttribute("href") === "/#contact");
|
||||
expect(contactLink).toHaveAccessibleName();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /currículo|download cv/i }),
|
||||
).not.toBeInTheDocument();
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#contact",
|
||||
);
|
||||
});
|
||||
|
||||
it("announces and controls the mobile menu", () => {
|
||||
@@ -70,23 +98,6 @@ describe("Header", () => {
|
||||
expect(
|
||||
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
|
||||
).toBe(mobileNavigation);
|
||||
expect(
|
||||
within(mobileNavigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/#modalities",
|
||||
"/cases",
|
||||
"/#process",
|
||||
"/#about",
|
||||
"/#experience",
|
||||
"/#contact",
|
||||
]);
|
||||
expect(
|
||||
within(mobileNavigation).queryByRole("link", {
|
||||
name: /currículo|download cv/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const mobileLink = within(mobileNavigation).getByRole("link", {
|
||||
name: "Sobre",
|
||||
@@ -98,34 +109,27 @@ describe("Header", () => {
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/#about", "Sobre"],
|
||||
["/#contact", "Contato"],
|
||||
])(
|
||||
"closes the mobile menu and preserves destination focus for %s",
|
||||
(href, destination) => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
render(<HomeWithHeader />);
|
||||
it("closes the mobile menu without taking focus away from a home destination", () => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
render(<HomeWithHeader />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||
const mobileNavigation = screen.getByRole("navigation", {
|
||||
name: "Navegação mobile",
|
||||
});
|
||||
const mobileLink = within(mobileNavigation)
|
||||
.getAllByRole("link")
|
||||
.find((link) => link.getAttribute("href") === href);
|
||||
if (!mobileLink)
|
||||
throw new Error(`Missing navigation destination: ${href}`);
|
||||
mobileLink.focus();
|
||||
fireEvent.click(mobileLink);
|
||||
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);
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
+43
-55
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
|
||||
import { type KeyboardEvent, useRef, useState } from "react";
|
||||
import {
|
||||
CV_DOWNLOAD_NAME,
|
||||
CV_URL,
|
||||
NAVIGATION_ITEMS,
|
||||
} from "@/lib/site-navigation";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
@@ -9,23 +13,8 @@ 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>) => {
|
||||
@@ -37,53 +26,49 @@ export function Header() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
|
||||
scrolled
|
||||
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
|
||||
: "border-transparent bg-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
|
||||
<a
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||
>
|
||||
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||
<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>
|
||||
</a>
|
||||
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="hidden items-center xl:flex"
|
||||
className="site-header-nav items-center"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-4">
|
||||
<ul className="flex list-none items-center gap-sp-2">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={item.href}
|
||||
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"
|
||||
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}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
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"
|
||||
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)]"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||
@@ -104,7 +89,7 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen ? (
|
||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] max-h-[calc(100dvh-var(--site-header-height))] overflow-y-auto border-b border-white/12 bg-[var(--ink)] px-sp-6 lg:px-sp-8 xl:hidden">
|
||||
<div className="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="ds-section-shell flex flex-col py-sp-6">
|
||||
<nav
|
||||
id={MENU_ID}
|
||||
@@ -117,11 +102,10 @@ export function Header() {
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
strength={0}
|
||||
wrapperClassName="w-full"
|
||||
href={item.href}
|
||||
className="flex min-h-11 items-center rounded-sm px-sp-3 py-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
||||
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-[padding,color] hover:pl-sp-2 hover:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
strength={0.15}
|
||||
>
|
||||
{item.label}
|
||||
</MagneticButton>
|
||||
@@ -130,12 +114,16 @@ export function Header() {
|
||||
<li>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
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}
|
||||
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}
|
||||
>
|
||||
Falar sobre meu projeto
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -8,6 +8,13 @@ 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();
|
||||
|
||||
+100
-25
@@ -3,6 +3,12 @@ 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");
|
||||
|
||||
@@ -14,6 +20,9 @@ jest.mock("motion/react", () => {
|
||||
children,
|
||||
initial: _initial,
|
||||
animate: _animate,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
variants: _variants,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
@@ -24,11 +33,53 @@ jest.mock("motion/react", () => {
|
||||
},
|
||||
);
|
||||
|
||||
return { motion, useReducedMotion: () => false };
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -36,31 +87,37 @@ describe("Hero", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes project conversations to contact without opening an external service", () => {
|
||||
it("renders the published command-center content and primary actions", () => {
|
||||
render(<Hero />);
|
||||
|
||||
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(
|
||||
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(
|
||||
"href",
|
||||
"/cases",
|
||||
"/Profile.pdf",
|
||||
);
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"Sistemas sob medida para operações que não cabem em soluções prontas.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps social destinations with isolated external tabs", () => {
|
||||
it("keeps social destinations, operational proof and WhatsApp action", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
|
||||
@@ -75,10 +132,28 @@ describe("Hero", () => {
|
||||
"href",
|
||||
"mailto:[email protected]",
|
||||
);
|
||||
for (const name of ["GitHub", "LinkedIn"]) {
|
||||
const link = screen.getByRole("link", { name });
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
+457
-138
@@ -1,188 +1,507 @@
|
||||
import Link from "next/link";
|
||||
import { type CSSProperties, Fragment } from "react";
|
||||
import HeroField from "./cinematic/HeroField";
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
|
||||
import { CV_URL } from "@/lib/site-navigation";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
|
||||
import { HeroStarfield } from "./cinematic/HeroStarfield";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
|
||||
|
||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
|
||||
export type HeroStats = {
|
||||
years?: number;
|
||||
projects?: number;
|
||||
demos?: number;
|
||||
};
|
||||
const 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 statItems(stats?: HeroStats) {
|
||||
if (!stats) return [];
|
||||
return [
|
||||
stats.years ? [String(stats.years), "anos de experiência"] : null,
|
||||
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
|
||||
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
|
||||
].filter((item): item is [string, string] => item !== null);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||
const TITLE =
|
||||
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
|
||||
" ",
|
||||
);
|
||||
const ACCENT = new Set([1, 2]);
|
||||
export default function Hero() {
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true);
|
||||
|
||||
/** O que cada camada da cena significa, em linguagem simples. */
|
||||
const CAPTIONS = [
|
||||
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
|
||||
[
|
||||
"As integrações",
|
||||
"Os programas que você já usa conversando entre si, sem retrabalho.",
|
||||
],
|
||||
["Os dados", "Suas informações guardadas, organizadas e seguras."],
|
||||
] as const;
|
||||
useEffect(() => {
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||
const items = statItems(stats);
|
||||
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="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
|
||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
>
|
||||
<div className="hero-stage">
|
||||
<div className="hero-glow" aria-hidden="true" />
|
||||
<HeroField />
|
||||
<div className="hero-scrim" aria-hidden="true" />
|
||||
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||
<ol className="hero-captions">
|
||||
{CAPTIONS.map(([title, text], index) => (
|
||||
<li key={title} style={{ "--i": index } as CSSProperties}>
|
||||
<span className="hero-caption-title">{title}</span>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
|
||||
|
||||
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
|
||||
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
|
||||
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||
Evandro Fonseca Junior · Engenharia de software
|
||||
</p>
|
||||
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
|
||||
{TITLE.map((word, index) => (
|
||||
<Fragment key={word}>
|
||||
<span
|
||||
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
|
||||
style={{ "--i": index } as CSSProperties}
|
||||
<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],
|
||||
}
|
||||
}
|
||||
>
|
||||
{word}
|
||||
</span>{" "}
|
||||
</Fragment>
|
||||
))}
|
||||
</h1>
|
||||
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
|
||||
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
|
||||
entendimento do negócio à arquitetura e à entrega em produção.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
||||
<motion.div
|
||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||
variants={heroItem}
|
||||
>
|
||||
Conversar sobre meu projeto
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
<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}
|
||||
>
|
||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/cases"
|
||||
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
|
||||
>
|
||||
Ver projetos entregues
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<span className="relative z-10">{HERO_CONTENT.cta}</span>
|
||||
</MagneticButton>
|
||||
|
||||
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
|
||||
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||
{items.length > 0 && (
|
||||
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
|
||||
{items.map(([value, label]) => (
|
||||
<div key={label} className="flex flex-col-reverse">
|
||||
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
|
||||
<div className="max-lg:hidden">
|
||||
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
||||
{["Sistemas web", "Aplicativos", "Integrações"].map(
|
||||
(service) => (
|
||||
<li key={service} className="flex items-center gap-sp-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-1 w-1 bg-[var(--slash)]"
|
||||
/>
|
||||
{service}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-x-sp-5">
|
||||
<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) => (
|
||||
<a
|
||||
<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="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"
|
||||
className="cinematic-social-link inline-flex h-11 w-11 items-center justify-center rounded-full"
|
||||
strength={0.4}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
<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>
|
||||
))}
|
||||
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
|
||||
Santa Catarina · Atendimento remoto
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<p
|
||||
aria-hidden="true"
|
||||
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
|
||||
|
||||
<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],
|
||||
}
|
||||
}
|
||||
>
|
||||
Role para mergulhar
|
||||
<span className="hero-cue-line" />
|
||||
</p>
|
||||
</div>
|
||||
<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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={
|
||||
shouldReduceMotion ? { duration: 0 } : { delay: 2, duration: 1 }
|
||||
}
|
||||
>
|
||||
{shouldReduceMotion ? (
|
||||
<div className="flex flex-col items-center gap-2" aria-hidden="true">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-2"
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -242,7 +242,7 @@ export default function LighthouseMetrics() {
|
||||
<div className="ds-section-shell">
|
||||
<div className="quality-intro">
|
||||
<header className="quality-copy">
|
||||
<p className="ds-eyebrow">Critérios de entrega</p>
|
||||
<p className="ds-eyebrow">Quality & Performance</p>
|
||||
<h2 id="quality-title" className="ds-section-title">
|
||||
Engenharia de ponta a ponta.
|
||||
<span>Qualidade em cada camada.</span>
|
||||
@@ -252,7 +252,10 @@ 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="/#contact">
|
||||
<a
|
||||
className="ds-link ds-link-accent"
|
||||
href="/servicos/consultoria-nextjs"
|
||||
>
|
||||
Conversar sobre a qualidade do seu projeto
|
||||
</a>
|
||||
</header>
|
||||
@@ -287,7 +290,10 @@ export default function LighthouseMetrics() {
|
||||
/>
|
||||
<span className="quality-control-content">
|
||||
<span className="quality-control-name">{metric.label}</span>
|
||||
<span className="quality-control-target">Ver critérios</span>
|
||||
<span className="quality-control-target">
|
||||
Meta <strong>100</strong>
|
||||
<span>/100</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -328,8 +334,8 @@ export default function LighthouseMetrics() {
|
||||
</section>
|
||||
|
||||
<p className="quality-disclaimer">
|
||||
Exemplos de critérios avaliados conforme o projeto. Este painel não
|
||||
representa uma auditoria, certificação ou garantia de resultado.
|
||||
Metas de qualidade, não uma auditoria em tempo real. Resultados
|
||||
Lighthouse variam conforme página, dispositivo e condições do teste.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+174
-14
@@ -1,25 +1,185 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface LogoProps {
|
||||
theme?: "dark" | "light";
|
||||
variant?: "mark" | "wordmark" | "icon";
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
theme?: "dark" | "light" | "accent";
|
||||
className?: string;
|
||||
loading?: "eager" | "lazy";
|
||||
showTagline?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: { width: 32, height: 32 },
|
||||
md: { width: 48, height: 48 },
|
||||
lg: { width: 64, height: 64 },
|
||||
xl: { width: 120, height: 80 },
|
||||
};
|
||||
|
||||
export function Logo({
|
||||
variant = "mark",
|
||||
size = "md",
|
||||
theme = "dark",
|
||||
className = "w-48",
|
||||
loading = "lazy",
|
||||
className = "",
|
||||
showTagline = false,
|
||||
}: Readonly<LogoProps>) {
|
||||
const dimensions = sizeMap[size];
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const colors = {
|
||||
dark: {
|
||||
primary: "#FFFFFF",
|
||||
slash: "#CCFF00",
|
||||
},
|
||||
light: {
|
||||
primary: "#0A0A0A",
|
||||
slash: "#0A0A0A",
|
||||
},
|
||||
accent: {
|
||||
primary: "#0A0A0A",
|
||||
slash: "rgba(0,0,0,.3)",
|
||||
},
|
||||
};
|
||||
|
||||
const c = colors[theme];
|
||||
|
||||
const hoverMotion = shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: 1.03,
|
||||
rotate: -2,
|
||||
};
|
||||
|
||||
const hoverTransition = {
|
||||
type: "spring" as const,
|
||||
stiffness: 280,
|
||||
damping: 18,
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro icon logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro icon logo</title>
|
||||
<rect width="64" height="64" rx="14" fill={c.primary} />
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="7"
|
||||
height="44"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="10"
|
||||
width="18"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="28.5"
|
||||
width="14"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<rect
|
||||
x="17"
|
||||
y="47"
|
||||
width="18"
|
||||
height="7"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
<polygon points="37,38 41,38 50,26 46,26" fill={c.slash} />
|
||||
<circle
|
||||
cx="52"
|
||||
cy="47"
|
||||
r="5"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "wordmark") {
|
||||
return (
|
||||
<motion.div
|
||||
className={`flex items-center gap-3 ${className}`}
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 48 32"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Evandro wordmark logo"
|
||||
>
|
||||
<title>Evandro wordmark logo</title>
|
||||
<rect x="2" y="2" width="7" height="28" fill={c.primary} />
|
||||
<rect x="9" y="2" width="20" height="7" fill={c.primary} />
|
||||
<rect x="9" y="12.5" width="16" height="7" fill={c.primary} />
|
||||
<rect x="9" y="23" width="20" height="7" fill={c.primary} />
|
||||
<polygon points="30,22 34,22 42,10 38,10" fill={c.slash} />
|
||||
<circle cx="44" cy="25" r="4" fill={c.primary} />
|
||||
</svg>
|
||||
<div>
|
||||
<div
|
||||
className="font-display text-lg font-extrabold tracking-tight"
|
||||
style={{ color: c.primary }}
|
||||
>
|
||||
evandro<span style={{ color: c.slash }}>.</span>dev
|
||||
<span style={{ color: c.slash }}>.</span>br
|
||||
</div>
|
||||
{showTagline && (
|
||||
<div
|
||||
className="mt-1 text-[9px] tracking-widest"
|
||||
style={{
|
||||
color:
|
||||
theme === "dark"
|
||||
? "rgba(255,255,255,.25)"
|
||||
: "rgba(10,10,10,.4)",
|
||||
}}
|
||||
>
|
||||
DESENVOLVIMENTO DE SOFTWARE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`/brand/wordmark-${theme}.webp`}
|
||||
alt="Evandro.dev.br"
|
||||
width={1600}
|
||||
height={330}
|
||||
sizes="240px"
|
||||
loading={loading}
|
||||
className={`block h-auto max-w-full ${className}`}
|
||||
/>
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 120 80"
|
||||
fill="none"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro mark logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro mark logo</title>
|
||||
<rect x="10" y="8" width="9" height="64" fill={c.primary} />
|
||||
<rect x="19" y="8" width="28" height="9" fill={c.primary} />
|
||||
<rect x="19" y="35.5" width="22" height="9" fill={c.primary} />
|
||||
<rect x="19" y="63" width="28" height="9" fill={c.primary} />
|
||||
<polygon points="50,55 57,55 74,25 67,25" fill={c.slash} />
|
||||
<circle cx="80" cy="63" r="7" fill={c.primary} />
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
+104
-120
@@ -3,10 +3,8 @@
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { ProjectCover } from "./ProjectCover";
|
||||
import { Section } from "./Section";
|
||||
|
||||
export default function Projects({
|
||||
@@ -16,11 +14,24 @@ export default function Projects({
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||
@@ -30,135 +41,108 @@ export default function Projects({
|
||||
return (
|
||||
<Section
|
||||
id="projects"
|
||||
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."
|
||||
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."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="ds-grid-fluid">
|
||||
{projects.map((project) => {
|
||||
const caseStudy = caseStudies.find(
|
||||
(entry) => entry.id === project.id,
|
||||
);
|
||||
return (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: "some" }}
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<ProjectCover project={project} />
|
||||
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<MagneticButton
|
||||
as="span"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||
>
|
||||
{project.status}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">
|
||||
{caseStudy?.summary ?? project.description}
|
||||
</p>
|
||||
{caseStudy && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">O desafio</p>
|
||||
<p className="ds-copy">{caseStudy.impact}</p>
|
||||
</div>
|
||||
)}
|
||||
{caseStudy?.role && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">Minha participação</p>
|
||||
<p className="ds-copy">{caseStudy.role}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
|
||||
{caseStudy && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.2}
|
||||
>
|
||||
Entender a solução
|
||||
</MagneticButton>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
</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 && (
|
||||
<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.githubUrl}
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver codigo
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoUrl &&
|
||||
(project.demoAvailability === "available" ||
|
||||
project.demoAvailability === "unknown") && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoAvailability === "unavailable" && (
|
||||
<span className="ds-link ds-link-accent" aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{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">
|
||||
<MagneticButton
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Section } from "./Section";
|
||||
interface Tier {
|
||||
id: string;
|
||||
badge: string;
|
||||
level: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
@@ -23,84 +24,65 @@ interface Tier {
|
||||
}
|
||||
|
||||
const TIERS: Tier[] = [
|
||||
{
|
||||
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:
|
||||
"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: [
|
||||
"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: "Proposta por projeto",
|
||||
pricingNote: "Escopo, etapas e investimento definidos antes da execução.",
|
||||
featured: true,
|
||||
ctaLabel: "Conversar sobre meu sistema",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
{
|
||||
id: "discovery",
|
||||
badge: "Antes de investir",
|
||||
title: "Diagnóstico de um sistema existente",
|
||||
tagline: "Entenda o que aproveitar e o que precisa mudar.",
|
||||
badge: "Entrada / Baixo Risco",
|
||||
level: "01",
|
||||
title: "Auditoria & Diagnóstico Técnico",
|
||||
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
|
||||
description:
|
||||
"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.",
|
||||
"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.",
|
||||
deliverables: [
|
||||
"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",
|
||||
"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",
|
||||
],
|
||||
pricing: "Escopo de análise definido",
|
||||
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
|
||||
ctaLabel: "Avaliar meu projeto",
|
||||
ctaHref: "/#contact",
|
||||
pricing: "Valor Fixo",
|
||||
pricingNote: "Contrato único. Entrega em até 5 dias úteis.",
|
||||
featured: false,
|
||||
ctaLabel: "Solicitar diagnóstico",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
{
|
||||
id: "evolution",
|
||||
badge: "Continuidade",
|
||||
title: "Integração e evolução",
|
||||
tagline: "Faça o que já existe trabalhar melhor.",
|
||||
id: "cycles",
|
||||
badge: "Mais Escolhido",
|
||||
level: "02",
|
||||
title: "Ciclos de Engenharia",
|
||||
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
|
||||
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.",
|
||||
"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: [
|
||||
"Integração entre sistemas e equipamentos",
|
||||
"Novos fluxos e automações",
|
||||
"Testes das partes afetadas",
|
||||
"Planejamento de manutenção e operação",
|
||||
"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: "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.",
|
||||
pricing: "Fixo por Ciclo",
|
||||
pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.",
|
||||
featured: true,
|
||||
ctaLabel: "Iniciar ciclo de engenharia",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
{
|
||||
title: "Definir a entrega",
|
||||
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.",
|
||||
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.",
|
||||
"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.",
|
||||
deliverables: [
|
||||
"SLA prioritário Zero-Risk",
|
||||
"Code review e lideranca do time interno",
|
||||
"Decisoes de infra: Cloud, VPS, CI/CD",
|
||||
"Consultoria estrategica recorrente",
|
||||
],
|
||||
pricing: "Retainer Mensal",
|
||||
pricingNote: "Renovação mensal. Vagas limitadas.",
|
||||
featured: false,
|
||||
ctaLabel: "Agendar conversa estratégica",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -130,6 +112,9 @@ 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} />
|
||||
@@ -165,7 +150,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
|
||||
{tier.pricing}
|
||||
</div>
|
||||
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]">
|
||||
<div className="text-[12px] leading-[1.7] text-[var(--meta)]">
|
||||
{tier.pricingNote}
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,23 +199,23 @@ export function WorkModalities() {
|
||||
return (
|
||||
<Section
|
||||
id="modalities"
|
||||
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."
|
||||
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."
|
||||
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">Reconhece algum destes cenários?</div>
|
||||
<div className="ds-kicker">O que você pode contratar</div>
|
||||
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
|
||||
Planilhas demais. Sistemas isolados. Trabalho repetido.
|
||||
Uma solução para a sua rotina, não uma lista de tecnologias.
|
||||
</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
|
||||
A operação cresceu, mas as ferramentas não acompanharam. Ou a
|
||||
ideia precisa sair do protótipo para atender clientes. O projeto
|
||||
começa pelo problema, sem exigir que você já saiba qual tecnologia
|
||||
usar.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<MagneticButton
|
||||
@@ -253,10 +238,10 @@ export function WorkModalities() {
|
||||
href={service.href}
|
||||
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
|
||||
>
|
||||
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
|
||||
<div className="text-base font-semibold leading-snug text-[var(--ink)]">
|
||||
{service.label}
|
||||
</div>
|
||||
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]">
|
||||
<div className="mt-sp-2 text-sm leading-6 text-[var(--ink-80)]">
|
||||
{service.description}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -277,28 +262,6 @@ 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>
|
||||
);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { TextDiffuseReveal } from "./TextDiffuseReveal";
|
||||
|
||||
let mockReducedMotion = false;
|
||||
let mockInView = true;
|
||||
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
return {
|
||||
motion: {
|
||||
span: ({
|
||||
children,
|
||||
initial: _initial,
|
||||
animate,
|
||||
transition: _transition,
|
||||
onAnimationComplete: _onAnimationComplete,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: Record<string, unknown>;
|
||||
transition?: unknown;
|
||||
onAnimationComplete?: () => void;
|
||||
} & Record<string, unknown>) => {
|
||||
mockAnimations.push({ animate });
|
||||
return React.createElement("span", props, children);
|
||||
},
|
||||
},
|
||||
useInView: () => mockInView,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
};
|
||||
});
|
||||
|
||||
describe("TextDiffuseReveal reduced motion", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
mockInView = true;
|
||||
mockAnimations = [];
|
||||
});
|
||||
|
||||
it("clears blur and scale when reduced motion resolves after mount", () => {
|
||||
const { rerender } = render(<TextDiffuseReveal text="Reveal" />);
|
||||
|
||||
mockAnimations = [];
|
||||
mockReducedMotion = true;
|
||||
rerender(<TextDiffuseReveal text="Reveal" />);
|
||||
|
||||
expect(mockAnimations).toHaveLength(6);
|
||||
expect(mockAnimations).toEqual(
|
||||
expect.arrayContaining(
|
||||
Array.from({ length: 6 }, () =>
|
||||
expect.objectContaining({
|
||||
animate: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
scale: 1,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the first render independent of the reduced-motion preference", () => {
|
||||
mockReducedMotion = true;
|
||||
mockInView = false;
|
||||
|
||||
render(<TextDiffuseReveal text="Reveal" />);
|
||||
|
||||
expect(mockAnimations[0]).toEqual({
|
||||
animate: {
|
||||
filter: "blur(12px)",
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useInView } from "motion/react";
|
||||
import { type CSSProperties, useRef } from "react";
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface TextDiffuseRevealProps {
|
||||
text: string;
|
||||
@@ -11,12 +12,8 @@ interface TextDiffuseRevealProps {
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência
|
||||
* e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com
|
||||
* desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições.
|
||||
* Movimento reduzido: o CSS mostra o texto direto, sem animação.
|
||||
*/
|
||||
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
export function TextDiffuseReveal({
|
||||
text,
|
||||
className = "",
|
||||
@@ -25,35 +22,111 @@ export function TextDiffuseReveal({
|
||||
amount = 0.3,
|
||||
}: Readonly<TextDiffuseRevealProps>) {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isInView = useInView(ref, { once, amount });
|
||||
let word = 0;
|
||||
const [staggerDelay, setStaggerDelay] = useState(0.03);
|
||||
const [completedChars, setCompletedChars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(max-width: 768px)");
|
||||
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
|
||||
|
||||
updateStagger();
|
||||
media.addEventListener("change", updateStagger);
|
||||
return () => media.removeEventListener("change", updateStagger);
|
||||
}, []);
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
let offset = 0;
|
||||
return text
|
||||
.split(/(\n|\s+)/)
|
||||
.filter(Boolean)
|
||||
.map((value) => {
|
||||
const key = `${offset}:${value}`;
|
||||
offset += value.length;
|
||||
return { key, value };
|
||||
});
|
||||
}, [text]);
|
||||
const animatedChars = useMemo(
|
||||
() =>
|
||||
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
|
||||
[text],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) {
|
||||
setCompletedChars(0);
|
||||
}
|
||||
}, [isInView]);
|
||||
|
||||
const hasActiveWillChange =
|
||||
!shouldReduceMotion && isInView && completedChars < animatedChars;
|
||||
|
||||
let animatedIndex = 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={`text-reveal ${className}`.trim()}
|
||||
data-shown={isInView ? "" : undefined}
|
||||
style={{ "--d": `${startDelay}s` } as CSSProperties}
|
||||
className={className}
|
||||
style={{
|
||||
willChange: hasActiveWillChange
|
||||
? "filter, opacity, transform"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{text
|
||||
.split(/(\n|\s+)/)
|
||||
.filter(Boolean)
|
||||
.map((token, index) => {
|
||||
const key = `${index}:${token}`;
|
||||
if (token === "\n") return <br key={key} />;
|
||||
if (/^\s+$/.test(token)) return " ";
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="text-reveal-word"
|
||||
style={{ "--i": word++ } as CSSProperties}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{tokens.map(({ key, value: token }) => {
|
||||
if (token === "\n") {
|
||||
return <br key={key} />;
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(token)) {
|
||||
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={key} className="inline-block whitespace-nowrap">
|
||||
{Array.from(token).map((char) => {
|
||||
const currentIndex = animatedIndex;
|
||||
animatedIndex += 1;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={`${key}-${currentIndex}-${char}`}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
animate={
|
||||
isInView
|
||||
? { filter: "blur(0px)", opacity: 1, scale: 1 }
|
||||
: shouldReduceMotion
|
||||
? { filter: "blur(0px)", opacity: 0, scale: 1 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.3 : 0.6,
|
||||
ease: EXPO_OUT,
|
||||
delay: startDelay + currentIndex * staggerDelay,
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
if (!shouldReduceMotion) {
|
||||
setCompletedChars((count) =>
|
||||
count < animatedChars ? count + 1 : count,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,103 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
import ScrollVelocityText from "./ScrollVelocityText";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
|
||||
function mockReducedMotion(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("prefers-reduced-motion") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("static cinematic effects", () => {
|
||||
it("renders grain without a canvas", () => {
|
||||
beforeEach(() => mockReducedMotion(false));
|
||||
|
||||
it("renders grain and every divider without a canvas", () => {
|
||||
const grain = render(<FilmGrain />);
|
||||
expect(grain.container.firstElementChild).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
grain.unmount();
|
||||
|
||||
for (const variant of ["gradient", "pulse", "waveform"] as const) {
|
||||
const divider = render(<SectionDivider variant={variant} />);
|
||||
expect(divider.container.firstElementChild).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
expect(divider.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
expect(divider.container.firstElementChild).not.toHaveAttribute(
|
||||
"tabindex",
|
||||
);
|
||||
divider.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a persistent pause control for an automatic marquee", () => {
|
||||
render(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
|
||||
|
||||
const pause = screen.getByRole("button", { name: "Pausar animação" });
|
||||
const track = document.getElementById(
|
||||
pause.getAttribute("aria-controls") ?? "",
|
||||
);
|
||||
expect(pause).toHaveAttribute("aria-pressed", "false");
|
||||
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
|
||||
expect(
|
||||
screen.getByText("SOFTWARE ENGINEERING", {
|
||||
selector: '[aria-hidden="false"]',
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2);
|
||||
expect(
|
||||
screen
|
||||
.getAllByText("SOFTWARE ENGINEERING")
|
||||
.slice(1)
|
||||
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(pause);
|
||||
|
||||
const resume = screen.getByRole("button", { name: "Retomar animação" });
|
||||
expect(resume).toHaveAttribute("aria-pressed", "true");
|
||||
expect(track).toHaveStyle({ animationPlayState: "paused" });
|
||||
|
||||
fireEvent.click(resume);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pausar animação" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
|
||||
});
|
||||
|
||||
it("starts static under reduced motion without a redundant control", () => {
|
||||
mockReducedMotion(true);
|
||||
render(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /animação/i }),
|
||||
).not.toBeInTheDocument();
|
||||
const copies = screen.getAllByText("VAMOS CONVERSAR");
|
||||
expect(copies).toHaveLength(2);
|
||||
expect(copies[0]).toBeVisible();
|
||||
expect(copies[0]).toHaveAttribute("aria-hidden", "false");
|
||||
expect(copies[0].closest("[style]")).toHaveStyle({
|
||||
animationPlayState: "paused",
|
||||
});
|
||||
expect(
|
||||
copies
|
||||
.slice(1)
|
||||
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,12 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
resetHomeArrivalState,
|
||||
useHomeFragmentNavigation,
|
||||
} from "./useHomeFragmentNavigation";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
function HomeNavigation() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={onLayoutReady}>
|
||||
Establish layout
|
||||
Establish notebook layout
|
||||
</button>
|
||||
<a href="/#about">Sobre</a>
|
||||
<a href="/#contact">Contato</a>
|
||||
@@ -26,13 +23,11 @@ function HomeNavigation() {
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeEach(() => {
|
||||
resetHomeArrivalState();
|
||||
window.history.replaceState(null, "", "/");
|
||||
HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
if (originalScrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
@@ -42,59 +37,20 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("home fragment navigation", () => {
|
||||
it("starts an unfragmented visit at the top after the home establishes layout", () => {
|
||||
let restoredPosition = 3200;
|
||||
jest
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation((options: ScrollToOptions | number) => {
|
||||
if (typeof options === "object")
|
||||
restoredPosition = options.top ?? restoredPosition;
|
||||
});
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(restoredPosition).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a same-document return to the position the browser restores", () => {
|
||||
const scrollTo = jest
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation(() => {});
|
||||
const firstVisit = render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
firstVisit.unmount();
|
||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||
|
||||
scrollTo.mockClear();
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||
});
|
||||
|
||||
it("does not reset the visitor's position after intentional scrolling during startup", () => {
|
||||
let position = 0;
|
||||
jest
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation((options: ScrollToOptions | number) => {
|
||||
if (typeof options === "object") position = options.top ?? position;
|
||||
});
|
||||
render(<HomeNavigation />);
|
||||
position = 600;
|
||||
fireEvent.wheel(window, { deltaY: 600 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(position).toBe(600);
|
||||
});
|
||||
|
||||
it("waits for the home layout on fragment arrival, then never steals focus on refresh", () => {
|
||||
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
render(<HomeNavigation />);
|
||||
const contact = screen.getByRole("region", { name: "Contato" });
|
||||
expect(contact).not.toHaveFocus();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(contact).toHaveFocus();
|
||||
screen.getByRole("button", { name: "Read biography" }).focus();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read biography" }),
|
||||
).toHaveFocus();
|
||||
@@ -106,14 +62,18 @@ describe("home fragment navigation", () => {
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
fireEvent.wheel(window, { deltaY: 200 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "Sobre" });
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
@@ -135,7 +95,9 @@ describe("home fragment navigation", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
PortfolioProjectView,
|
||||
@@ -8,63 +7,73 @@ 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, { type HeroStats } from "../Hero";
|
||||
import Hero from "../Hero";
|
||||
import LighthouseMetrics from "../LighthouseMetrics";
|
||||
import Projects from "../Projects";
|
||||
import Skills from "../Skills";
|
||||
import NotebookStory from "../story/NotebookStory";
|
||||
import { WorkModalities } from "../WorkModalities";
|
||||
import ExperienceDisclosure from "./ExperienceDisclosure";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import ScrollVelocityText from "./ScrollVelocityText";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
export default function CinematicHome({
|
||||
projects,
|
||||
experiences,
|
||||
skillCategories,
|
||||
heroStats,
|
||||
}: {
|
||||
heroStats?: HeroStats;
|
||||
projects: PortfolioProjectView[];
|
||||
experiences: PortfolioExperience[];
|
||||
skillCategories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(mainRef);
|
||||
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
|
||||
useEffect(() => onLayoutReady(), [onLayoutReady]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
|
||||
<main ref={mainRef} className="ds-main relative z-0">
|
||||
<Hero stats={heroStats} />
|
||||
<main className="ds-main">
|
||||
<Hero />
|
||||
|
||||
{/* Marquee divider after hero */}
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
|
||||
</div>
|
||||
|
||||
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
||||
|
||||
<About />
|
||||
|
||||
<SectionDivider variant="waveform" />
|
||||
|
||||
<Skills categories={skillCategories} />
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
|
||||
<ScrollVelocityText
|
||||
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
|
||||
direction="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Experience experiences={experiences} />
|
||||
|
||||
<SectionDivider variant="gradient" />
|
||||
|
||||
<Projects projects={projects} />
|
||||
|
||||
<WorkModalities />
|
||||
|
||||
<EngineeringWithAI />
|
||||
|
||||
<LighthouseMetrics />
|
||||
|
||||
<div className="bg-[var(--paper)]">
|
||||
<About />
|
||||
<SectionDivider variant="pulse" />
|
||||
|
||||
<ExperienceDisclosure>
|
||||
<Skills categories={skillCategories} />
|
||||
<Experience experiences={experiences} />
|
||||
</ExperienceDisclosure>
|
||||
<WorkModalities />
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
|
||||
</div>
|
||||
|
||||
<Contact />
|
||||
</main>
|
||||
<div className="relative z-10">
|
||||
<Footer />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MOVEMENT_DAMPING = 1400;
|
||||
|
||||
const JOINVILLE_LAT = -26.3044;
|
||||
const JOINVILLE_LNG = -48.8456;
|
||||
// cobe espera radianos: theta = latitude da banda de frente (Trópico de Capricórnio), phi = longitude de frente (Joinville)
|
||||
const TROPIC_OF_CAPRICORN_RAD = (-23.4364 * Math.PI) / 180;
|
||||
const JOINVILLE_LNG_RAD = (JOINVILLE_LNG * Math.PI) / 180;
|
||||
|
||||
const CONTROL_ROOM_GLOBE_CONFIG: COBEOptions = {
|
||||
width: 800,
|
||||
height: 800,
|
||||
onRender: () => {},
|
||||
devicePixelRatio: 2,
|
||||
phi: JOINVILLE_LNG_RAD,
|
||||
theta: TROPIC_OF_CAPRICORN_RAD,
|
||||
dark: 0,
|
||||
diffuse: 1.2,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 6,
|
||||
baseColor: [0.32, 0.32, 0.32],
|
||||
markerColor: [204 / 255, 255 / 255, 0 / 255],
|
||||
glowColor: [204 / 255, 255 / 255, 0 / 255],
|
||||
markers: [
|
||||
{ location: [JOINVILLE_LAT, JOINVILLE_LNG], size: 0.12 },
|
||||
{ location: [-25.4284, -49.2733], size: 0.06 },
|
||||
{ location: [-26.0167, -48.5], size: 0.05 },
|
||||
{ location: [-25.9333, -48.7833], size: 0.04 },
|
||||
],
|
||||
} as const satisfies COBEOptions;
|
||||
|
||||
export function ControlRoomGlobe({
|
||||
className,
|
||||
config = CONTROL_ROOM_GLOBE_CONFIG,
|
||||
autoRotate = true,
|
||||
rotationSpeed = 0.003,
|
||||
}: {
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
}) {
|
||||
const widthRef = useRef(0);
|
||||
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
|
||||
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
|
||||
const thetaPhaseRef = useRef(0);
|
||||
const thetaDragRef = useRef(0);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerYOrigin = useRef<number | null>(null);
|
||||
const intersectsRef = useRef(false);
|
||||
|
||||
const r = useMotionValue(0);
|
||||
const rs = useSpring(r, {
|
||||
mass: 1,
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
});
|
||||
|
||||
const updatePointerInteraction = (value: number | null) => {
|
||||
pointerInteracting.current = value;
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
|
||||
}
|
||||
};
|
||||
|
||||
const updateMovement = (clientX: number, clientY?: number) => {
|
||||
if (pointerInteracting.current !== null) {
|
||||
const delta = clientX - pointerInteracting.current;
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING);
|
||||
// Eixo vertical: arrasto inclina o globo (limitado p/ não virar de cabeça pra baixo)
|
||||
if (clientY !== undefined && pointerYOrigin.current !== null) {
|
||||
const deltaY = clientY - pointerYOrigin.current;
|
||||
thetaDragRef.current = Math.max(
|
||||
-0.9,
|
||||
Math.min(0.9, thetaDragRef.current + deltaY / MOVEMENT_DAMPING),
|
||||
);
|
||||
pointerYOrigin.current = clientY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
|
||||
const dpr = config.devicePixelRatio;
|
||||
const updateWidth = (entry?: ResizeObserverEntry) => {
|
||||
const nextWidth =
|
||||
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
|
||||
if (nextWidth > 0) widthRef.current = nextWidth;
|
||||
};
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
intersectsRef.current = entry.isIntersecting;
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
intersectionObserver.observe(canvas);
|
||||
|
||||
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
|
||||
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
|
||||
let globe: { destroy(): void } | null = null;
|
||||
try {
|
||||
const supportsWebGL =
|
||||
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
if (supportsWebGL) {
|
||||
const initialRenderWidth = widthRef.current * dpr;
|
||||
globe = createGlobe(canvas, {
|
||||
...config,
|
||||
width: initialRenderWidth,
|
||||
height: initialRenderWidth,
|
||||
onRender: (state) => {
|
||||
const renderWidth = widthRef.current * dpr;
|
||||
state.width = renderWidth;
|
||||
state.height = renderWidth;
|
||||
if (!autoRotate || !intersectsRef.current || document.hidden)
|
||||
return;
|
||||
if (!pointerInteracting.current) {
|
||||
phiRef.current += rotationSpeed;
|
||||
thetaPhaseRef.current += rotationSpeed * 0.9;
|
||||
}
|
||||
state.phi = phiRef.current + rs.get();
|
||||
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
|
||||
state.theta =
|
||||
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
||||
Math.sin(thetaPhaseRef.current) * 0.35 +
|
||||
thetaDragRef.current;
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
globe = null;
|
||||
}
|
||||
|
||||
if (!globe) {
|
||||
// Sem globo: fallback visível (gradiente do card permanece), página viva.
|
||||
canvas.style.opacity = "1";
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
const revealTimer = window.setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(revealTimer);
|
||||
globe.destroy();
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [rs, config, autoRotate, rotationSpeed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto aspect-[1/1] w-full max-w-[520px]",
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<canvas
|
||||
className={cn(
|
||||
"size-full opacity-0 transition-opacity duration-700 [contain:layout_paint_size]",
|
||||
)}
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
pointerInteracting.current = e.clientX;
|
||||
pointerYOrigin.current = e.clientY;
|
||||
updatePointerInteraction(e.clientX);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
updatePointerInteraction(null);
|
||||
pointerYOrigin.current = null;
|
||||
}}
|
||||
onPointerOut={() => updatePointerInteraction(null)}
|
||||
onPointerLeave={() => {
|
||||
updatePointerInteraction(null);
|
||||
pointerYOrigin.current = null;
|
||||
}}
|
||||
onMouseMove={(e) => updateMovement(e.clientX, e.clientY)}
|
||||
onTouchMove={(e) =>
|
||||
e.touches[0] &&
|
||||
updateMovement(e.touches[0].clientX, e.touches[0].clientY)
|
||||
}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
Globo interativo centrado em Joinville, Santa Catarina, com marcadores
|
||||
indicando presença operacional na região Norte de SC e Curitiba.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
"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,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
direction: "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GROUPS = [0, 1] as const;
|
||||
|
||||
export default function ScrollVelocityText({
|
||||
text,
|
||||
direction,
|
||||
className = "",
|
||||
}: ScrollVelocityTextProps) {
|
||||
const trackId = useId();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setShouldReduceMotion(media.matches);
|
||||
const updatePreference = (event: MediaQueryListEvent) => {
|
||||
setShouldReduceMotion(event.matches);
|
||||
};
|
||||
media.addEventListener("change", updatePreference);
|
||||
return () => media.removeEventListener("change", updatePreference);
|
||||
}, []);
|
||||
|
||||
const animationPaused = shouldReduceMotion || isPaused;
|
||||
|
||||
return (
|
||||
<div className={`scroll-velocity-text ${className}`.trim()}>
|
||||
<div className="scroll-velocity-viewport">
|
||||
<div
|
||||
id={trackId}
|
||||
className={`scroll-velocity-track ${
|
||||
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
|
||||
}`}
|
||||
style={{
|
||||
animationPlayState: animationPaused ? "paused" : undefined,
|
||||
}}
|
||||
>
|
||||
{GROUPS.map((group) => (
|
||||
<span
|
||||
key={group}
|
||||
className="scroll-velocity-group"
|
||||
aria-hidden={group === 0 ? undefined : "true"}
|
||||
>
|
||||
<span className="scroll-velocity-item">
|
||||
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
|
||||
<span aria-hidden="true" className="scroll-velocity-dot">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shouldReduceMotion ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-velocity-control"
|
||||
aria-controls={trackId}
|
||||
aria-pressed={isPaused}
|
||||
onClick={() => setIsPaused((paused) => !paused)}
|
||||
>
|
||||
{isPaused ? "Retomar animação" : "Pausar animação"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
interface SectionDividerProps {
|
||||
variant?: "gradient" | "pulse" | "waveform";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const;
|
||||
|
||||
export default function SectionDivider({
|
||||
variant = "gradient",
|
||||
className = "",
|
||||
}: SectionDividerProps) {
|
||||
const rootClassName =
|
||||
`section-divider section-divider-${variant} ${className}`.trim();
|
||||
|
||||
if (variant === "gradient") {
|
||||
return (
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pulse") {
|
||||
return (
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
{PULSE_DOTS.map((dot) => (
|
||||
<span key={dot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
|
||||
<title>Divisor decorativo</title>
|
||||
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
|
||||
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
|
||||
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -16,17 +16,6 @@ function focusFragment(target: HTMLElement) {
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A document owns one outstanding homepage arrival. Later mounts are same-document
|
||||
* history, where the browser restores its own position.
|
||||
*/
|
||||
let arrivalSettled = false;
|
||||
|
||||
/** Tests replay several documents inside one jsdom document. */
|
||||
export function resetHomeArrivalState() {
|
||||
arrivalSettled = false;
|
||||
}
|
||||
|
||||
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
|
||||
export function useHomeFragmentNavigation() {
|
||||
const ready = useRef(false);
|
||||
@@ -37,49 +26,22 @@ export function useHomeFragmentNavigation() {
|
||||
| PerformanceNavigationTiming
|
||||
| undefined;
|
||||
// A history restoration owns its saved position, not the URL's old fragment.
|
||||
const restoration = navigation?.type === "back_forward";
|
||||
const arrival = !restoration && !arrivalSettled;
|
||||
let pending = restoration ? "" : window.location.hash;
|
||||
let pendingTop = arrival && !window.location.hash;
|
||||
if (arrival) window.history.scrollRestoration = "manual";
|
||||
let pending =
|
||||
navigation?.type === "back_forward" ? "" : window.location.hash;
|
||||
|
||||
const resolve = () => {
|
||||
if (!ready.current) return;
|
||||
if (pendingTop) {
|
||||
pendingTop = false;
|
||||
if (!window.location.hash)
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}
|
||||
if (!pending) return;
|
||||
if (!ready.current || !pending) return;
|
||||
const hash = pending;
|
||||
pending = "";
|
||||
if (hash !== window.location.hash) return;
|
||||
const target = fragmentTarget(hash);
|
||||
if (!target) return;
|
||||
focusFragment(target);
|
||||
const padding =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const margin =
|
||||
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
|
||||
window.scrollTo({
|
||||
top: Math.max(
|
||||
0,
|
||||
window.scrollY +
|
||||
target.getBoundingClientRect().top -
|
||||
padding -
|
||||
margin,
|
||||
),
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
target.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
};
|
||||
|
||||
const cancelArrival = () => {
|
||||
pending = "";
|
||||
pendingTop = false;
|
||||
window.history.scrollRestoration = "auto";
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -126,13 +88,11 @@ 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);
|
||||
@@ -149,7 +109,6 @@ 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);
|
||||
@@ -162,7 +121,5 @@ export function useHomeFragmentNavigation() {
|
||||
return useCallback(() => {
|
||||
ready.current = true;
|
||||
resolveArrival.current?.();
|
||||
arrivalSettled = true;
|
||||
window.history.scrollRestoration = "auto";
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import type { NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
type CursorFrame = Pick<
|
||||
NotebookFrame,
|
||||
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
||||
>;
|
||||
export type NotebookBrowserHandle = {
|
||||
updateFrame: (frame: CursorFrame) => void;
|
||||
};
|
||||
|
||||
export type NotebookBrowserProps = {
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
frame: CursorFrame;
|
||||
ref?: Ref<NotebookBrowserHandle>;
|
||||
onSelectTab: (index: number) => void;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
function toDomId(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
export default function NotebookBrowser({
|
||||
projects,
|
||||
activeIndex,
|
||||
frame,
|
||||
onSelectTab,
|
||||
interactive = true,
|
||||
ref,
|
||||
}: NotebookBrowserProps) {
|
||||
const selectedIndex = clampIndex(activeIndex, projects.length);
|
||||
const selectedProject =
|
||||
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const cursorRef = useRef<SVGSVGElement>(null);
|
||||
const cursorFrame = useRef(frame);
|
||||
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const browserSize = useRef({ width: 0, height: 0 });
|
||||
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
|
||||
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
const requestedSrc = selectedProject?.screenshotSrc;
|
||||
const requestRef = useRef(requestedSrc);
|
||||
const requestVersion = useRef(0);
|
||||
const displayedRef = useRef(displayed);
|
||||
const mounted = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
requestRef.current = requestedSrc;
|
||||
requestVersion.current += 1;
|
||||
setFailedSrc(null);
|
||||
}, [requestedSrc]);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Only the immediate neighbours are fetched; the browser cache handles reuse.
|
||||
useEffect(() => {
|
||||
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
|
||||
.filter((project): project is NotebookProject => Boolean(project))
|
||||
.map((project) => {
|
||||
const image = new window.Image();
|
||||
image.decoding = "async";
|
||||
image.src = project.screenshotSrc;
|
||||
return image;
|
||||
});
|
||||
return () => {
|
||||
images.forEach((image) => {
|
||||
image.removeAttribute("src");
|
||||
});
|
||||
};
|
||||
}, [projects, selectedIndex]);
|
||||
|
||||
const updateFrame = useCallback((next: CursorFrame) => {
|
||||
cursorFrame.current = next;
|
||||
const cursor = cursorRef.current;
|
||||
const points = tabPoints.current;
|
||||
if (!cursor || points.length === 0) return;
|
||||
const position = Math.max(
|
||||
0,
|
||||
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
|
||||
);
|
||||
const left = Math.floor(position);
|
||||
const right = Math.min(left + 1, points.length - 1);
|
||||
const blend = position - left;
|
||||
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
|
||||
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
|
||||
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
|
||||
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
|
||||
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
|
||||
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
|
||||
cursor.style.opacity = next.cursorVisible ? "1" : "0";
|
||||
cursor.classList.toggle("is-clicking", next.cursorClicking);
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
const measure = () => {
|
||||
browserSize.current = {
|
||||
width: root.clientWidth,
|
||||
height: root.clientHeight,
|
||||
};
|
||||
const points = [];
|
||||
for (const project of projects) {
|
||||
const tab: HTMLButtonElement | null =
|
||||
root.querySelector<HTMLButtonElement>(
|
||||
`#notebook-tab-${toDomId(project.id)}`,
|
||||
);
|
||||
if (!tab) continue;
|
||||
let x = tab.offsetWidth / 2;
|
||||
let y = tab.offsetHeight / 2;
|
||||
let element: HTMLElement | null = tab;
|
||||
while (element && element !== root) {
|
||||
x += element.offsetLeft;
|
||||
y += element.offsetTop;
|
||||
element = element.offsetParent as HTMLElement | null;
|
||||
}
|
||||
points.push({ x, y });
|
||||
}
|
||||
tabPoints.current = points;
|
||||
updateFrame(cursorFrame.current);
|
||||
};
|
||||
measure();
|
||||
if (!("ResizeObserver" in window)) return undefined;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, [projects, updateFrame]);
|
||||
useLayoutEffect(() => {
|
||||
updateFrame(frame);
|
||||
}, [frame, updateFrame]);
|
||||
|
||||
const revealImage = useCallback(
|
||||
async (image: HTMLImageElement, project: NotebookProject) => {
|
||||
const version = requestVersion.current;
|
||||
try {
|
||||
if (typeof image.decode === "function") await image.decode();
|
||||
} catch {
|
||||
if (
|
||||
mounted.current &&
|
||||
version === requestVersion.current &&
|
||||
requestRef.current === project.screenshotSrc
|
||||
) {
|
||||
setFailedSrc(project.screenshotSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!mounted.current ||
|
||||
version !== requestVersion.current ||
|
||||
requestRef.current !== project.screenshotSrc
|
||||
)
|
||||
return;
|
||||
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
|
||||
setOutgoing(
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
? null
|
||||
: displayedRef.current,
|
||||
);
|
||||
displayedRef.current = project;
|
||||
setDisplayed(project);
|
||||
setFailedSrc(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (!selectedProject) return;
|
||||
// A quick reversal can select the still-mounted outgoing image: no new load event.
|
||||
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
|
||||
.href;
|
||||
const image = Array.from(
|
||||
rootRef.current?.querySelectorAll("img") ?? [],
|
||||
).find((item) => item.src === source);
|
||||
if (image?.complete && image.naturalWidth > 0) {
|
||||
void revealImage(image, selectedProject);
|
||||
}
|
||||
}, [selectedProject, revealImage]);
|
||||
|
||||
const imageProjects = [outgoing, displayed, selectedProject].filter(
|
||||
(project, index, list): project is NotebookProject =>
|
||||
Boolean(project) &&
|
||||
list.findIndex(
|
||||
(item) => item?.screenshotSrc === project?.screenshotSrc,
|
||||
) === index,
|
||||
);
|
||||
const waiting = displayed?.screenshotSrc !== requestedSrc;
|
||||
const interactionProps = interactive
|
||||
? {}
|
||||
: {
|
||||
"aria-hidden": true,
|
||||
inert: true,
|
||||
};
|
||||
|
||||
function handleTabKeyDown(
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
index: number,
|
||||
) {
|
||||
if (projects.length < 1) return;
|
||||
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||
nextIndex = Math.min(index + 1, projects.length - 1);
|
||||
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||
nextIndex = Math.max(index - 1, 0);
|
||||
} else if (event.key === "Home") {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === "End") {
|
||||
nextIndex = projects.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
||||
[nextIndex]?.focus({ preventScroll: true });
|
||||
onSelectTab(nextIndex);
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
data-empty
|
||||
>
|
||||
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedId = toDomId(selectedProject.id);
|
||||
const panelId = `notebook-panel-${selectedId}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
ref={rootRef}
|
||||
>
|
||||
<div className="notebook-browser-bar" aria-hidden="true">
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-dot" />
|
||||
<span className="notebook-browser-address">
|
||||
evandro.dev.br / projetos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="notebook-browser-body">
|
||||
<div className="notebook-browser-heading">
|
||||
<span className="notebook-browser-eyebrow">Projetos em produção</span>
|
||||
<span className="notebook-browser-counter">
|
||||
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
|
||||
{String(projects.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<output className="sr-only" aria-live="polite">
|
||||
Projeto ativo: {selectedProject.title}
|
||||
</output>
|
||||
|
||||
<div
|
||||
className="notebook-tablist"
|
||||
role="tablist"
|
||||
aria-label="Projetos em produção"
|
||||
>
|
||||
{projects.map((project, index) => {
|
||||
const tabId = `notebook-tab-${toDomId(project.id)}`;
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-selected={isSelected}
|
||||
className="notebook-tab"
|
||||
id={tabId}
|
||||
key={project.id}
|
||||
onClick={() => onSelectTab(index)}
|
||||
onKeyDown={(event) => handleTabKeyDown(event, index)}
|
||||
role="tab"
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<span className="notebook-tab-number">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="notebook-tab-title">{project.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-labelledby={`notebook-tab-${selectedId}`}
|
||||
className="notebook-panel"
|
||||
id={panelId}
|
||||
role="tabpanel"
|
||||
>
|
||||
<div className="notebook-panel-copy">
|
||||
<h3>{selectedProject.title}</h3>
|
||||
<p>{selectedProject.subtitle}</p>
|
||||
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
|
||||
{selectedProject.technologies.slice(0, 4).map((technology) => (
|
||||
<li key={technology}>{technology}</li>
|
||||
))}
|
||||
</ul>
|
||||
{selectedProject.demoAvailability === "unavailable" ? (
|
||||
<span aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
) : selectedProject.demoAvailability === "none" ||
|
||||
!selectedProject.demoUrl ? null : (
|
||||
<a
|
||||
href={selectedProject.demoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Abrir projeto <span aria-hidden="true">↗</span>
|
||||
{selectedProject.demoAvailability === "unknown" ? (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="notebook-screenshot-wrap"
|
||||
aria-busy={waiting && failedSrc !== requestedSrc}
|
||||
>
|
||||
{imageProjects.map((project) => {
|
||||
const current =
|
||||
project.screenshotSrc === displayed?.screenshotSrc;
|
||||
const previous =
|
||||
project.screenshotSrc === outgoing?.screenshotSrc;
|
||||
return (
|
||||
<Image
|
||||
key={project.screenshotSrc}
|
||||
alt={current ? `Captura de tela de ${project.title}` : ""}
|
||||
aria-hidden={!current}
|
||||
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
|
||||
width={project.screenshotWidth}
|
||||
height={project.screenshotHeight}
|
||||
loading="eager"
|
||||
sizes="(min-width: 1025px) 800px, 100vw"
|
||||
src={project.screenshotSrc}
|
||||
unoptimized
|
||||
onLoad={(event) => {
|
||||
void revealImage(event.currentTarget, project);
|
||||
}}
|
||||
onError={() => {
|
||||
if (project.screenshotSrc === requestRef.current)
|
||||
setFailedSrc(project.screenshotSrc);
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
if (current) setOutgoing(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{waiting ? (
|
||||
<p className="notebook-screenshot-status" aria-live="polite">
|
||||
{failedSrc === requestedSrc
|
||||
? `Não foi possível carregar a captura de ${selectedProject.title}.`
|
||||
: `Carregando captura de ${selectedProject.title}…`}
|
||||
{displayed
|
||||
? ` Exibindo ${displayed.title} enquanto isso.`
|
||||
: null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="notebook-cursor"
|
||||
ref={cursorRef}
|
||||
viewBox="0 0 28 36"
|
||||
width="28"
|
||||
height="36"
|
||||
>
|
||||
<title>Cursor decorativo</title>
|
||||
<path
|
||||
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
|
||||
fill="#0a0a0a"
|
||||
stroke="#f4f2ed"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
className="notebook-cursor-pulse"
|
||||
cx="2"
|
||||
cy="2"
|
||||
r="8"
|
||||
fill="none"
|
||||
stroke="#ccff00"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const frame: NotebookBrowserProps["frame"] = {
|
||||
cursorVisible: true,
|
||||
cursorX: 0.4,
|
||||
cursorY: 0.6,
|
||||
cursorClicking: false,
|
||||
};
|
||||
|
||||
function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
|
||||
return render(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={0}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function imageFor(container: HTMLElement, index: number) {
|
||||
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
|
||||
const image = Array.from(container.querySelectorAll("img")).find(
|
||||
(candidate) => candidate.src === source,
|
||||
);
|
||||
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
|
||||
return image;
|
||||
}
|
||||
|
||||
describe("NotebookBrowser", () => {
|
||||
it("renders the active project as an accessible tabpanel", () => {
|
||||
renderBrowser({ activeIndex: 1 });
|
||||
|
||||
expect(
|
||||
screen.getByRole("tablist", { name: /projetos em produção/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("tab")[1]).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
|
||||
/SC Plus Aquicultura/i,
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Projeto ativo: SC Plus Aquicultura",
|
||||
);
|
||||
});
|
||||
it("keeps decoded content visible and ignores a superseded decode", async () => {
|
||||
const view = renderBrowser();
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 0));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
let finishDecode!: () => void;
|
||||
imageFor(view.container, 1).decode = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDecode = resolve;
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 1));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={2}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 2));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
await act(async () => {
|
||||
finishDecode();
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
});
|
||||
|
||||
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
|
||||
const view = renderBrowser();
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 0));
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.error(imageFor(view.container, 1));
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
expect(
|
||||
screen.getByText(/não foi possível carregar.*SC Plus/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
|
||||
});
|
||||
|
||||
it("can reverse to an already-loaded outgoing image without another load event", async () => {
|
||||
const view = renderBrowser();
|
||||
const first = imageFor(view.container, 0);
|
||||
Object.defineProperties(first, {
|
||||
complete: { value: true },
|
||||
naturalWidth: { value: 1280 },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.load(first);
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 1));
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={0}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("img", { name: /NutriScan/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it("moves between tabs with arrow keys and Home/End", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectTab = jest.fn();
|
||||
renderBrowser({ onSelectTab });
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
|
||||
await user.click(tabs[1]);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
await user.keyboard("{Home}");
|
||||
await user.keyboard("{End}");
|
||||
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(2, 2);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
||||
});
|
||||
it("moves DOM focus with the active tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tabs =
|
||||
renderBrowser().container.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="tab"]',
|
||||
);
|
||||
|
||||
tabs[0].focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
expect(document.activeElement).toBe(tabs[1]);
|
||||
});
|
||||
|
||||
it("removes the closed browser from the accessibility tree", () => {
|
||||
renderBrowser({ interactive: false });
|
||||
|
||||
const browser = screen.getByTestId("notebook-browser");
|
||||
expect(browser).toHaveAttribute("aria-hidden", "true");
|
||||
expect(browser).toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("bounds arrow navigation at the first and last tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectTab = jest.fn();
|
||||
renderBrowser({ onSelectTab });
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
|
||||
await user.click(tabs[0]);
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
await user.click(tabs.at(-1) as HTMLElement);
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(2, 0);
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
||||
});
|
||||
|
||||
it("keeps an unknown demo link and announces its state", () => {
|
||||
const unknownProjects = projects.map((project, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unknown" as const,
|
||||
demoCheckedAt: null,
|
||||
}
|
||||
: project,
|
||||
);
|
||||
renderBrowser({ projects: unknownProjects });
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: /abrir projeto.*disponibilidade não verificada/i,
|
||||
});
|
||||
expect(link).toHaveAttribute("href", projects[0].demoUrl);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("renders an unavailable demo as non-navigable text", () => {
|
||||
const unavailableProjects = projects.map((project, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unavailable" as const,
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
}
|
||||
: project,
|
||||
);
|
||||
renderBrowser({ projects: unavailableProjects });
|
||||
|
||||
const unavailable = screen.getByText(
|
||||
"Demo indisponível na última verificação",
|
||||
);
|
||||
expect(unavailable).toHaveAttribute("aria-disabled", "true");
|
||||
expect(unavailable.closest("a")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /abrir projeto/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no demo action for a project without a demo", () => {
|
||||
const noDemoProjects = [
|
||||
{
|
||||
...projects[0],
|
||||
demoUrl: null,
|
||||
demoAvailability: "none",
|
||||
demoCheckedAt: null,
|
||||
},
|
||||
] as unknown as NotebookBrowserProps["projects"];
|
||||
renderBrowser({ projects: noDemoProjects });
|
||||
|
||||
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Demo indisponível na última verificação"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the selected tab to label the panel and decoded screenshot", async () => {
|
||||
const view = renderBrowser({ activeIndex: 2 });
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 2));
|
||||
});
|
||||
|
||||
const tab = screen.getAllByRole("tab")[2];
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
const image = screen.getByRole("img");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-controls", panel.id);
|
||||
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
|
||||
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
expect(image).toHaveProperty(
|
||||
"src",
|
||||
new URL(projects[2].screenshotSrc, document.baseURI).href,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import NotebookBrowser from "./NotebookBrowser";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
|
||||
export type NotebookFallbackProps = {
|
||||
projects: NotebookProject[];
|
||||
initialIndex?: number;
|
||||
onSelectTab?: (index: number) => void;
|
||||
};
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookFallback({
|
||||
projects,
|
||||
initialIndex = 0,
|
||||
onSelectTab,
|
||||
}: NotebookFallbackProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(() =>
|
||||
clampIndex(initialIndex, projects.length),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notebook-fallback" data-testid="notebook-fallback">
|
||||
<div className="notebook-fallback-intro">
|
||||
<span className="notebook-fallback-kicker">Interface em produção</span>
|
||||
<p>
|
||||
Navegue pelos projetos com as abas abaixo. A experiência permanece
|
||||
completa sem movimento ou WebGL.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookBrowser
|
||||
activeIndex={activeIndex}
|
||||
frame={{
|
||||
cursorClicking: false,
|
||||
cursorVisible: false,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
}}
|
||||
onSelectTab={(index) => {
|
||||
setActiveIndex(index);
|
||||
onSelectTab?.(index);
|
||||
}}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
|
||||
describe("NotebookFallback", () => {
|
||||
it("keeps project content and controls in normal document flow", () => {
|
||||
const { container } = render(<NotebookFallback projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tabpanel")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows manual tab changes without an animated stage", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<NotebookFallback projects={projects} />);
|
||||
|
||||
await user.click(screen.getAllByRole("tab")[2]);
|
||||
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
|
||||
/SEV Moda Íntima/i,
|
||||
);
|
||||
expect(container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects the requested initial project", () => {
|
||||
render(<NotebookFallback initialIndex={3} projects={projects} />);
|
||||
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i);
|
||||
expect(screen.getAllByRole("tab")[3]).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
"use client";
|
||||
|
||||
import { Html, RoundedBox } from "@react-three/drei";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
export type NotebookStage3DProps = {
|
||||
progress: ProgressRef;
|
||||
requestRender: { current: (() => void) | null };
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
isVisible: boolean;
|
||||
onStageError?: () => void;
|
||||
};
|
||||
|
||||
type NotebookSceneProps = NotebookStage3DProps;
|
||||
|
||||
/**
|
||||
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
|
||||
* resting on the deck), 112° = open (screen facing the camera, reclined
|
||||
* ~22° past vertical). The lid pivots around the hinge axis — never around
|
||||
* the display center.
|
||||
*/
|
||||
export function getNotebookLidRotation(lidAngle: number): number {
|
||||
const angle = THREE.MathUtils.clamp(lidAngle, 0, 180);
|
||||
const radians = THREE.MathUtils.degToRad(angle);
|
||||
return radians === 0 ? 0 : -radians;
|
||||
}
|
||||
|
||||
// The laptop and its DOM screen share the same world-space geometry.
|
||||
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
|
||||
const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
|
||||
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
|
||||
|
||||
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
|
||||
const HINGE: [number, number, number] = [0, 0.16, -1.06];
|
||||
const LID = { width: 3.2, thickness: 0.1, depth: 2.2 };
|
||||
const SCREEN_INSET = { width: 2.9, depth: 1.96 };
|
||||
const SCREEN_GLASS = { width: 2.8, depth: 1.86 };
|
||||
|
||||
const KEY_COLUMNS = 12;
|
||||
const KEY_ROWS = 5;
|
||||
const KEY_SIZE: [number, number, number] = [0.185, 0.05, 0.155];
|
||||
const KEY_GAP_X = 0.212;
|
||||
const KEY_GAP_Z = 0.2;
|
||||
const KEYBOARD_Z_START = -0.9;
|
||||
const KEYBOARD_Y = 0.185;
|
||||
const SPACEBAR_Z = 0.12;
|
||||
|
||||
const SCREEN_DOM_WIDTH = 800;
|
||||
const SCREEN_DOM_HEIGHT =
|
||||
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
|
||||
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
|
||||
.makeScale(
|
||||
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
|
||||
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
|
||||
1,
|
||||
)
|
||||
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
|
||||
const SCREEN_ORIGIN: [number, number] = [0, 0];
|
||||
const getScreenOrigin = () => SCREEN_ORIGIN;
|
||||
|
||||
export function updateNotebookScreenMatrix(
|
||||
matrix: THREE.Matrix4,
|
||||
screen: THREE.Object3D,
|
||||
camera: THREE.Camera,
|
||||
size: { width: number; height: number },
|
||||
): THREE.Matrix4 {
|
||||
screen.updateWorldMatrix(true, false);
|
||||
camera.updateMatrixWorld();
|
||||
const halfWidth = size.width / 2;
|
||||
const halfHeight = size.height / 2;
|
||||
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
|
||||
matrix
|
||||
.set(
|
||||
halfWidth,
|
||||
0,
|
||||
0,
|
||||
halfWidth,
|
||||
0,
|
||||
-halfHeight,
|
||||
0,
|
||||
halfHeight,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.multiply(camera.projectionMatrix)
|
||||
.multiply(camera.matrixWorldInverse)
|
||||
.multiply(screen.matrixWorld)
|
||||
.multiply(SCREEN_PIXELS_TO_WORLD);
|
||||
return matrix.multiplyScalar(1 / matrix.elements[15]);
|
||||
}
|
||||
|
||||
type LaptopMaterials = {
|
||||
chassis: THREE.MeshStandardMaterial;
|
||||
keys: THREE.MeshStandardMaterial;
|
||||
dark: THREE.MeshStandardMaterial;
|
||||
accent: THREE.MeshStandardMaterial;
|
||||
screen: THREE.MeshStandardMaterial;
|
||||
};
|
||||
|
||||
function createLaptopMaterials(): LaptopMaterials {
|
||||
return {
|
||||
chassis: new THREE.MeshStandardMaterial({
|
||||
color: "#3f545b",
|
||||
metalness: 0.35,
|
||||
roughness: 0.32,
|
||||
transparent: true,
|
||||
}),
|
||||
keys: new THREE.MeshStandardMaterial({
|
||||
color: "#5a747c",
|
||||
metalness: 0.2,
|
||||
roughness: 0.5,
|
||||
transparent: true,
|
||||
}),
|
||||
dark: new THREE.MeshStandardMaterial({
|
||||
color: "#131f24",
|
||||
metalness: 0.15,
|
||||
roughness: 0.55,
|
||||
transparent: true,
|
||||
}),
|
||||
accent: new THREE.MeshStandardMaterial({
|
||||
color: "#465f66",
|
||||
metalness: 0.3,
|
||||
roughness: 0.4,
|
||||
transparent: true,
|
||||
}),
|
||||
screen: new THREE.MeshStandardMaterial({
|
||||
color: "#0d1417",
|
||||
emissive: "#22343a",
|
||||
emissiveIntensity: 0.55,
|
||||
metalness: 0.1,
|
||||
roughness: 0.4,
|
||||
transparent: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
|
||||
materials.chassis.opacity = opacity;
|
||||
materials.keys.opacity = opacity;
|
||||
materials.dark.opacity = opacity;
|
||||
materials.accent.opacity = opacity;
|
||||
materials.screen.opacity = opacity;
|
||||
}
|
||||
|
||||
function disposeLaptopMaterials(materials: LaptopMaterials) {
|
||||
for (const material of Object.values(materials)) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
|
||||
const ref = useRef<THREE.InstancedMesh>(null);
|
||||
const positions = useMemo(() => {
|
||||
const list: Array<[number, number]> = [];
|
||||
const startX = (-(KEY_COLUMNS - 1) * KEY_GAP_X) / 2;
|
||||
for (let row = 0; row < KEY_ROWS; row += 1) {
|
||||
for (let column = 0; column < KEY_COLUMNS; column += 1) {
|
||||
list.push([
|
||||
startX + column * KEY_GAP_X,
|
||||
KEYBOARD_Z_START + row * KEY_GAP_Z,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mesh = ref.current;
|
||||
if (!(mesh instanceof THREE.InstancedMesh)) return;
|
||||
|
||||
const matrix = new THREE.Matrix4();
|
||||
positions.forEach(([x, z], index) => {
|
||||
matrix.makeTranslation(x, KEYBOARD_Y, z);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}, [positions]);
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
args={[undefined, undefined, positions.length]}
|
||||
material={material}
|
||||
ref={ref}
|
||||
>
|
||||
<boxGeometry args={KEY_SIZE} />
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
function LaptopBase({ materials }: { materials: LaptopMaterials }) {
|
||||
return (
|
||||
<group>
|
||||
<RoundedBox
|
||||
args={[BASE.width, BASE.height, BASE.depth]}
|
||||
material={materials.chassis}
|
||||
position={[0, BASE.height / 2, 0]}
|
||||
radius={0.05}
|
||||
smoothness={3}
|
||||
/>
|
||||
<RoundedBox
|
||||
args={[2.66, 0.05, 1.3]}
|
||||
material={materials.dark}
|
||||
position={[0, 0.155, -0.38]}
|
||||
radius={0.02}
|
||||
smoothness={2}
|
||||
/>
|
||||
<LaptopKeys material={materials.keys} />
|
||||
<mesh material={materials.keys} position={[0, KEYBOARD_Y, SPACEBAR_Z]}>
|
||||
<boxGeometry args={[1.05, KEY_SIZE[1], KEY_SIZE[2]]} />
|
||||
</mesh>
|
||||
<RoundedBox
|
||||
args={[1.15, 0.02, 0.6]}
|
||||
material={materials.accent}
|
||||
position={[0, 0.17, 0.68]}
|
||||
radius={0.01}
|
||||
smoothness={2}
|
||||
/>
|
||||
<mesh
|
||||
material={materials.dark}
|
||||
position={[0, 0.16, -1.06]}
|
||||
rotation={[0, 0, Math.PI / 2]}
|
||||
>
|
||||
<cylinderGeometry args={[0.05, 0.05, 2.9, 16]} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function LaptopLid({ materials }: { materials: LaptopMaterials }) {
|
||||
return (
|
||||
<group>
|
||||
<RoundedBox
|
||||
args={[LID.width, LID.thickness, LID.depth]}
|
||||
material={materials.chassis}
|
||||
position={[0, LID.thickness / 2, LID.depth / 2]}
|
||||
radius={0.05}
|
||||
smoothness={3}
|
||||
/>
|
||||
<RoundedBox
|
||||
args={[SCREEN_INSET.width, 0.05, SCREEN_INSET.depth]}
|
||||
material={materials.dark}
|
||||
position={[0, 0, LID.depth / 2]}
|
||||
radius={0.02}
|
||||
smoothness={2}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenSurface({
|
||||
frame,
|
||||
material,
|
||||
projects,
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
}: {
|
||||
frame: RefObject<NotebookFrame>;
|
||||
material: THREE.MeshStandardMaterial;
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
}) {
|
||||
const screen = useRef<THREE.Group>(null);
|
||||
const surface = useRef<HTMLDivElement>(null);
|
||||
const browser = useRef<NotebookBrowserHandle>(null);
|
||||
const projection = useMemo(() => new THREE.Matrix4(), []);
|
||||
useFrame(({ camera, size }) => {
|
||||
if (surface.current && screen.current) {
|
||||
updateNotebookScreenMatrix(projection, screen.current, camera, size);
|
||||
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
|
||||
surface.current.style.opacity = String(frame.current.screenOpacity);
|
||||
surface.current.inert = frame.current.screenOpacity < 0.95;
|
||||
surface.current.setAttribute(
|
||||
"aria-hidden",
|
||||
String(frame.current.screenOpacity < 0.95),
|
||||
);
|
||||
}
|
||||
browser.current?.updateFrame(frame.current);
|
||||
});
|
||||
return (
|
||||
<group
|
||||
ref={screen}
|
||||
position={[0, -0.028, LID.depth / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<mesh material={material}>
|
||||
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
|
||||
</mesh>
|
||||
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
|
||||
<div
|
||||
ref={surface}
|
||||
className="notebook-screen-surface"
|
||||
style={{
|
||||
width: SCREEN_DOM_WIDTH,
|
||||
height: SCREEN_DOM_HEIGHT,
|
||||
opacity: frame.current.screenOpacity,
|
||||
}}
|
||||
inert={frame.current.screenOpacity < 0.95}
|
||||
aria-hidden={frame.current.screenOpacity < 0.95}
|
||||
>
|
||||
<NotebookBrowser
|
||||
ref={browser}
|
||||
activeIndex={activeIndex}
|
||||
frame={frame.current}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookScene({
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
progress,
|
||||
projects,
|
||||
requestRender,
|
||||
isVisible,
|
||||
}: NotebookSceneProps) {
|
||||
const lidRig = useRef<THREE.Group>(null);
|
||||
const frame = useRef(getNotebookFrame(progress.current, projects.length));
|
||||
const materials = useMemo(createLaptopMaterials, []);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
|
||||
useEffect(() => {
|
||||
requestRender.current = isVisible ? invalidate : null;
|
||||
if (isVisible) invalidate();
|
||||
return () => {
|
||||
requestRender.current = null;
|
||||
};
|
||||
}, [invalidate, isVisible, requestRender]);
|
||||
|
||||
useFrame(() => {
|
||||
const nextFrame = getNotebookFrame(
|
||||
progress.current,
|
||||
projects.length,
|
||||
frame.current,
|
||||
);
|
||||
if (lidRig.current) {
|
||||
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
|
||||
}
|
||||
setLaptopOpacity(materials, nextFrame.deviceOpacity);
|
||||
}, -1);
|
||||
|
||||
return (
|
||||
<group position={RIG_POSITION}>
|
||||
<ambientLight intensity={1.35} />
|
||||
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
|
||||
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
|
||||
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
|
||||
<LaptopBase materials={materials} />
|
||||
<group position={HINGE} ref={lidRig}>
|
||||
<LaptopLid materials={materials} />
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
material={materials.screen}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class NotebookStageBoundary extends Component<
|
||||
NotebookStage3DProps,
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch() {
|
||||
this.props.onStageError?.();
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<NotebookFallback
|
||||
initialIndex={this.props.activeIndex}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{
|
||||
fov: 30,
|
||||
position: CAMERA_POSITION,
|
||||
rotation: CAMERA_ROTATION,
|
||||
}}
|
||||
className="notebook-story-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={this.props.isVisible ? "demand" : "never"}
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<NotebookScene {...this.props} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotebookStage3D(props: NotebookStage3DProps) {
|
||||
return (
|
||||
<div className="notebook-story-stage" data-testid="notebook-stage">
|
||||
<NotebookStageBoundary {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
getNotebookLidRotation,
|
||||
type NotebookStage3DProps,
|
||||
updateNotebookScreenMatrix,
|
||||
} from "./NotebookStage3D";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
type MockCanvasProps = {
|
||||
children: React.ReactNode;
|
||||
frameloop?: string;
|
||||
};
|
||||
|
||||
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
|
||||
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: (props: MockCanvasProps) => mockCanvas(props),
|
||||
useFrame: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@react-three/drei", () => ({
|
||||
Html: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="mock-html">{children}</div>
|
||||
),
|
||||
RoundedBox: () => <div data-testid="mock-roundedbox" />,
|
||||
}));
|
||||
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const props: NotebookStage3DProps = {
|
||||
activeIndex: 0,
|
||||
isVisible: true,
|
||||
onSelectTab: jest.fn(),
|
||||
progress: { current: 0.56 },
|
||||
requestRender: { current: null },
|
||||
projects,
|
||||
};
|
||||
|
||||
describe("getNotebookLidRotation", () => {
|
||||
it("keeps the lid closed at rest and opens around the hinge", () => {
|
||||
expect(getNotebookLidRotation(0)).toBe(0);
|
||||
expect(getNotebookLidRotation(112)).toBeCloseTo(
|
||||
-THREE.MathUtils.degToRad(112),
|
||||
6,
|
||||
);
|
||||
expect(getNotebookLidRotation(56)).toBeCloseTo(
|
||||
-THREE.MathUtils.degToRad(56),
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps angles outside the physical hinge range", () => {
|
||||
expect(getNotebookLidRotation(-10)).toBe(0);
|
||||
expect(getNotebookLidRotation(200)).toBe(getNotebookLidRotation(180));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser corners on the physical LCD after opening and resizing", () => {
|
||||
const matrix = new THREE.Matrix4();
|
||||
const rig = new THREE.Group();
|
||||
rig.position.set(0, -0.45, 0);
|
||||
const hinge = new THREE.Group();
|
||||
hinge.position.set(0, 0.16, -1.06);
|
||||
rig.add(hinge);
|
||||
const lcd = new THREE.Object3D();
|
||||
lcd.position.set(0, -0.028, 1.1);
|
||||
lcd.rotation.x = Math.PI / 2;
|
||||
hinge.add(lcd);
|
||||
const domWidth = 800;
|
||||
const domHeight = (domWidth * 1.86) / 2.8;
|
||||
|
||||
for (const size of [
|
||||
{ width: 939.390625, height: 738, lidAngle: 74 },
|
||||
{ width: 1123.375, height: 629.75, lidAngle: 112 },
|
||||
]) {
|
||||
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
|
||||
camera.position.set(0, 1.65, 5.7);
|
||||
camera.rotation.x = -0.19;
|
||||
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
|
||||
updateNotebookScreenMatrix(matrix, lcd, camera, size);
|
||||
|
||||
for (const [u, v] of [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
]) {
|
||||
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
|
||||
.applyMatrix4(lcd.matrixWorld)
|
||||
.project(camera);
|
||||
const actual = new THREE.Vector3(
|
||||
u * domWidth,
|
||||
v * domHeight,
|
||||
0,
|
||||
).applyMatrix4(matrix);
|
||||
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
|
||||
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("NotebookStage3D", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns usable browser content when the stage boundary fails", () => {
|
||||
const onStageError = jest.fn();
|
||||
mockCanvas.mockImplementation(() => {
|
||||
throw new Error("canvas failed");
|
||||
});
|
||||
|
||||
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
|
||||
expect(onStageError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||
|
||||
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProjectView[];
|
||||
onLayoutReady?: () => void;
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
if (typeof view.matchMedia !== "function") return false;
|
||||
return (
|
||||
view.innerWidth >= 1025 &&
|
||||
!view.matchMedia("(prefers-reduced-motion: reduce)").matches &&
|
||||
!view.matchMedia("(pointer: coarse)").matches &&
|
||||
view.matchMedia("(hover: hover)").matches
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotebookRunwayVh(tabCount: number): number {
|
||||
const count = Math.max(
|
||||
1,
|
||||
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
|
||||
);
|
||||
return 220 + count * 85;
|
||||
}
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookStory({
|
||||
projects,
|
||||
onLayoutReady,
|
||||
}: NotebookStoryProps) {
|
||||
const notebookProjects = useMemo(
|
||||
() => selectNotebookProjects(projects),
|
||||
[projects],
|
||||
);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||
const seekRef = useRef<((progress: number) => void) | null>(null);
|
||||
const layoutReadyRef = useRef(onLayoutReady);
|
||||
const failedRef = useRef(false);
|
||||
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
|
||||
const [capabilityResolved, setCapabilityResolved] = useState(false);
|
||||
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [sectionIntersects, setSectionIntersects] = useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => typeof document === "undefined" || !document.hidden,
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
layoutReadyRef.current = onLayoutReady;
|
||||
}, [onLayoutReady]);
|
||||
useEffect(() => {
|
||||
if (!engine && capabilityResolved) layoutReadyRef.current?.();
|
||||
}, [engine, capabilityResolved]);
|
||||
useEffect(() => {
|
||||
const updateEngine = () => {
|
||||
const nextEngine =
|
||||
!failedRef.current &&
|
||||
notebookProjects.length > 0 &&
|
||||
canUseNotebook3D(window);
|
||||
if (!nextEngine) setSectionIntersects(false);
|
||||
setEngine(nextEngine);
|
||||
setCapabilityResolved(true);
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
|
||||
const mediaQueries = [
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)"),
|
||||
window.matchMedia("(pointer: coarse)"),
|
||||
window.matchMedia("(hover: hover)"),
|
||||
];
|
||||
window.addEventListener("resize", updateEngine, { passive: true });
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.addEventListener("change", updateEngine);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateEngine);
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
mediaQuery.removeEventListener("change", updateEngine);
|
||||
});
|
||||
};
|
||||
}, [notebookProjects.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsPageVisible(!document.hidden);
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!engine || !section) return undefined;
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setSectionIntersects(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(section);
|
||||
return () => observer.disconnect();
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engine) return undefined;
|
||||
const sectionElement = sectionRef.current;
|
||||
if (!sectionElement) return undefined;
|
||||
const sectionRoot: HTMLElement = sectionElement;
|
||||
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
async function setupTimeline() {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
const context = gsap.context(() => {
|
||||
const driver = { value: 0 };
|
||||
const applyProgress = () => {
|
||||
progressRef.current = driver.value;
|
||||
const nextIndex = getNotebookFrame(
|
||||
driver.value,
|
||||
notebookProjects.length,
|
||||
frameRef.current,
|
||||
).activeTabIndex;
|
||||
if (nextIndex !== activeIndexRef.current) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
setActiveIndex(nextIndex);
|
||||
}
|
||||
requestRenderRef.current?.();
|
||||
};
|
||||
const timeline = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
onUpdate: applyProgress,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRoot,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
onRefresh: () => layoutReadyRef.current?.(),
|
||||
},
|
||||
});
|
||||
timeline.to(driver, { duration: 1, value: 1 });
|
||||
seekRef.current = (progress) => {
|
||||
const trigger = timeline.scrollTrigger;
|
||||
if (!trigger) return;
|
||||
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
|
||||
window.scrollTo({
|
||||
top: trigger.start + (trigger.end - trigger.start) * progress,
|
||||
behavior: "instant",
|
||||
});
|
||||
ScrollTrigger.update();
|
||||
timeline.progress(progress);
|
||||
applyProgress();
|
||||
};
|
||||
timeline.scrollTrigger?.refresh();
|
||||
}, sectionRoot);
|
||||
cleanup = () => {
|
||||
seekRef.current = null;
|
||||
context.revert();
|
||||
};
|
||||
}
|
||||
|
||||
void setupTimeline().catch(() => {
|
||||
if (!cancelled) {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [engine, notebookProjects.length]);
|
||||
|
||||
const handleStageError = useCallback(() => {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectTab = useCallback(
|
||||
(index: number) => {
|
||||
const safeIndex = clampIndex(index, notebookProjects.length);
|
||||
if (safeIndex < 0) return;
|
||||
activeIndexRef.current = safeIndex;
|
||||
setActiveIndex(safeIndex);
|
||||
if (engine) {
|
||||
seekRef.current?.(
|
||||
getNotebookSeekProgress(safeIndex, notebookProjects.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
[engine, notebookProjects.length],
|
||||
);
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="notebook-story-static-content">
|
||||
<div className="notebook-story-copy">
|
||||
<span className="notebook-story-kicker">O notebook</span>
|
||||
<h2>Software que opera.</h2>
|
||||
<p>
|
||||
Uma seleção navegável de sistemas reais, com suas interfaces e
|
||||
tecnologias em produção.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookFallback
|
||||
initialIndex={activeIndex}
|
||||
onSelectTab={handleSelectTab}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className="notebook-story notebook-story-runway"
|
||||
data-notebook-engine="3d"
|
||||
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="notebook-story-sticky">
|
||||
<div className="notebook-story-grid">
|
||||
<div className="notebook-story-copy">
|
||||
<span className="notebook-story-kicker">O notebook</span>
|
||||
<h2>Software que opera.</h2>
|
||||
<p>
|
||||
Role para abrir a engenharia por trás de cada entrega. O scroll é
|
||||
o controle de play e rebobinar.
|
||||
</p>
|
||||
<span className="notebook-story-count" aria-live="polite">
|
||||
{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "}
|
||||
{String(notebookProjects.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<NotebookStage3D
|
||||
activeIndex={activeIndex}
|
||||
isVisible={sectionIntersects && isPageVisible}
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
requestRender={requestRenderRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
|
||||
|
||||
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
|
||||
));
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
return function DynamicNotebookStage(props: { isVisible: boolean }) {
|
||||
return mockNotebookStage(props);
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })),
|
||||
context: jest.fn((callback: () => void) => {
|
||||
callback();
|
||||
return { revert: jest.fn() };
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
const projects = getFeaturedProjects(null);
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined;
|
||||
const mockObserve = jest.fn();
|
||||
const mockDisconnect = jest.fn();
|
||||
|
||||
function setDocumentHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: hidden,
|
||||
});
|
||||
}
|
||||
|
||||
function setSectionIntersects(isIntersecting: boolean) {
|
||||
const entry = { isIntersecting } as IntersectionObserverEntry;
|
||||
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
|
||||
}
|
||||
|
||||
function mockMatchMedia({
|
||||
reduced = false,
|
||||
coarse = false,
|
||||
hover = true,
|
||||
}: {
|
||||
reduced?: boolean;
|
||||
coarse?: boolean;
|
||||
hover?: boolean;
|
||||
} = {}) {
|
||||
const matcher = jest.isMockFunction(window.matchMedia)
|
||||
? window.matchMedia
|
||||
: jest.spyOn(window, "matchMedia");
|
||||
matcher.mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === "(prefers-reduced-motion: reduce)" && reduced) ||
|
||||
(query === "(pointer: coarse)" && coarse) ||
|
||||
(query === "(hover: hover)" && hover),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("NotebookStory", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1280,
|
||||
});
|
||||
setDocumentHidden(false);
|
||||
intersectionCallback = undefined;
|
||||
mockObserve.mockReset();
|
||||
mockDisconnect.mockReset();
|
||||
class MockIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = mockDisconnect;
|
||||
observe = mockObserve;
|
||||
takeRecords = () => [];
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.IntersectionObserver =
|
||||
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the static fallback when reduced motion is enabled", async () => {
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not initialize the 3D stage on coarse pointers", async () => {
|
||||
mockMatchMedia({ coarse: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays paused when the tab returns while the section is offscreen", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
const stage = await screen.findByTestId("notebook-stage");
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
|
||||
setSectionIntersects(true);
|
||||
await waitFor(() =>
|
||||
expect(stage).toHaveAttribute("data-is-visible", "true"),
|
||||
);
|
||||
|
||||
setDocumentHidden(true);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
await waitFor(() =>
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false"),
|
||||
);
|
||||
|
||||
setSectionIntersects(false);
|
||||
setDocumentHidden(false);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
});
|
||||
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the static fallback when no mapped projects are available", async () => {
|
||||
mockMatchMedia();
|
||||
|
||||
render(<NotebookStory projects={[]} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires desktop hover input without reduced motion", () => {
|
||||
const matchMedia = (query: string) =>
|
||||
({
|
||||
matches: query === "(hover: hover)",
|
||||
}) as MediaQueryList;
|
||||
|
||||
expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true);
|
||||
expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false);
|
||||
expect(
|
||||
canUseNotebook3D({
|
||||
innerWidth: 1280,
|
||||
matchMedia: (query) =>
|
||||
({
|
||||
matches:
|
||||
query === "(hover: hover)" ||
|
||||
query === "(prefers-reduced-motion: reduce)",
|
||||
}) as MediaQueryList,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes only mapped projects into the fallback browser", () => {
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /Twins Lab/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
|
||||
export type NotebookProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
demoUrl: PortfolioProjectView["demoUrl"];
|
||||
demoAvailability: PortfolioProjectView["demoAvailability"];
|
||||
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
|
||||
technologies: string[];
|
||||
screenshotSrc: string;
|
||||
screenshotWidth: number;
|
||||
screenshotHeight: number;
|
||||
};
|
||||
|
||||
const NOTEBOOK_ORDER = [
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
] as const;
|
||||
|
||||
const SCREENSHOT_BY_PROJECT_ID: Record<
|
||||
(typeof NOTEBOOK_ORDER)[number],
|
||||
string
|
||||
> = {
|
||||
nutriscan: "/assets/notebook/nutriscan.webp",
|
||||
"sc-plus": "/assets/notebook/sc-plus.webp",
|
||||
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
|
||||
"as-servicos": "/assets/notebook/as-servicos.webp",
|
||||
};
|
||||
|
||||
function isApprovedDemoUrl(
|
||||
value: PortfolioProjectView["demoUrl"],
|
||||
): value is string {
|
||||
return typeof value === "string" && /^https?:\/\//.test(value);
|
||||
}
|
||||
|
||||
export function selectNotebookProjects(
|
||||
projects: readonly PortfolioProjectView[],
|
||||
): NotebookProject[] {
|
||||
return NOTEBOOK_ORDER.flatMap((id) => {
|
||||
const project = projects.find((candidate) => candidate.id === id);
|
||||
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
|
||||
|
||||
if (
|
||||
!project ||
|
||||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
subtitle: project.subtitle,
|
||||
demoUrl: project.demoUrl,
|
||||
demoAvailability: project.demoAvailability,
|
||||
demoCheckedAt: project.demoCheckedAt,
|
||||
technologies: [...project.technologies],
|
||||
screenshotSrc,
|
||||
screenshotWidth: 1600,
|
||||
screenshotHeight: 1000,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
getFeaturedProjects,
|
||||
type PortfolioProjectView,
|
||||
} from "@/lib/portfolio-data";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projectsFixture = getFeaturedProjects(null);
|
||||
|
||||
describe("selectNotebookProjects", () => {
|
||||
it("returns featured projects in notebook order", () => {
|
||||
const result = selectNotebookProjects(projectsFixture);
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual([
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a project when its screenshot mapping is missing", () => {
|
||||
const result = selectNotebookProjects([
|
||||
...projectsFixture,
|
||||
{ ...projectsFixture[0], id: "unknown-live-project" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
result.some((project) => project.id === "unknown-live-project"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a project with an unapproved demo URL", () => {
|
||||
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? { ...project, demoUrl: "javascript:alert(1)" }
|
||||
: project,
|
||||
);
|
||||
const result = selectNotebookProjects(projectsWithUnsafeUrl);
|
||||
|
||||
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a project without a demo so its local evidence remains visible", () => {
|
||||
const projectsWithoutDemo = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? {
|
||||
...project,
|
||||
demoUrl: null,
|
||||
demoAvailability: "none" as const,
|
||||
demoCheckedAt: null,
|
||||
}
|
||||
: project,
|
||||
) as unknown as PortfolioProjectView[];
|
||||
|
||||
expect(
|
||||
selectNotebookProjects(projectsWithoutDemo).some(
|
||||
(project) => project.id === "nutriscan",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an unavailable project when its local evidence and URL exist", () => {
|
||||
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan"
|
||||
? {
|
||||
...project,
|
||||
demoAvailability: "unavailable" as const,
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
}
|
||||
: project,
|
||||
);
|
||||
|
||||
expect(
|
||||
selectNotebookProjects(projectsWithUnavailableDemo).find(
|
||||
(project) => project.id === "nutriscan",
|
||||
),
|
||||
).toMatchObject({
|
||||
demoAvailability: "unavailable",
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export type NotebookPhase =
|
||||
| "approach"
|
||||
| "open"
|
||||
| "boot"
|
||||
| "cursor-in"
|
||||
| "browse"
|
||||
| "hold"
|
||||
| "close";
|
||||
|
||||
export type NotebookFrame = {
|
||||
phase: NotebookPhase;
|
||||
lidAngle: number;
|
||||
deviceOpacity: number;
|
||||
screenOpacity: number;
|
||||
activeTabIndex: number;
|
||||
cursorVisible: boolean;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
cursorClicking: boolean;
|
||||
};
|
||||
|
||||
const OPEN_START = 0.06;
|
||||
const BOOT_START = 0.18;
|
||||
const CURSOR_START = 0.23;
|
||||
const BROWSE_START = 0.3;
|
||||
const BROWSE_END = 0.84;
|
||||
const CLOSE_START = 0.9;
|
||||
const CLOSE_LID_START = 0.93;
|
||||
const OPEN_ANGLE = 112;
|
||||
const ARRIVAL = 0.18;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function between(value: number, start: number, end: number): number {
|
||||
return clamp((value - start) / (end - start), 0, 1);
|
||||
}
|
||||
|
||||
export function getNotebookSeekProgress(
|
||||
index: number,
|
||||
tabCount: number,
|
||||
): number {
|
||||
const count = Number.isFinite(tabCount)
|
||||
? Math.max(0, Math.floor(tabCount))
|
||||
: 0;
|
||||
if (!count) return BROWSE_START;
|
||||
const selected = clamp(
|
||||
Number.isFinite(index) ? Math.floor(index) : 0,
|
||||
0,
|
||||
count - 1,
|
||||
);
|
||||
return (
|
||||
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
|
||||
);
|
||||
}
|
||||
|
||||
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
|
||||
export function getNotebookFrame(
|
||||
progress: number,
|
||||
tabCount: number,
|
||||
target?: NotebookFrame,
|
||||
): NotebookFrame {
|
||||
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
|
||||
const count = Number.isFinite(tabCount)
|
||||
? Math.max(0, Math.floor(tabCount))
|
||||
: 0;
|
||||
const phase: NotebookPhase =
|
||||
p < OPEN_START
|
||||
? "approach"
|
||||
: p < BOOT_START
|
||||
? "open"
|
||||
: p < CURSOR_START
|
||||
? "boot"
|
||||
: p < BROWSE_START
|
||||
? "cursor-in"
|
||||
: p < BROWSE_END
|
||||
? "browse"
|
||||
: p < CLOSE_START
|
||||
? "hold"
|
||||
: "close";
|
||||
const slot = between(p, BROWSE_START, BROWSE_END) * count;
|
||||
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
|
||||
const local = slot - destination;
|
||||
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
|
||||
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
|
||||
const travel = between(local, 0, ARRIVAL);
|
||||
const cursorTab = moving
|
||||
? destination - 1 + travel * travel * (3 - 2 * travel)
|
||||
: destination;
|
||||
const entry = between(p, CURSOR_START, BROWSE_START);
|
||||
const frame = target ?? ({} as NotebookFrame);
|
||||
frame.phase = phase;
|
||||
frame.lidAngle =
|
||||
OPEN_ANGLE *
|
||||
between(p, OPEN_START, BOOT_START) *
|
||||
(1 - between(p, CLOSE_LID_START, 1));
|
||||
frame.deviceOpacity = between(p, 0, OPEN_START);
|
||||
frame.screenOpacity =
|
||||
between(p, BOOT_START, CURSOR_START) *
|
||||
(1 - between(p, CLOSE_START, CLOSE_LID_START));
|
||||
frame.activeTabIndex = activeTabIndex;
|
||||
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
|
||||
frame.cursorX = count
|
||||
? phase === "cursor-in"
|
||||
? 0.5 + (0.5 / count - 0.5) * entry
|
||||
: (cursorTab + 0.5) / count
|
||||
: 0.5;
|
||||
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
|
||||
frame.cursorClicking =
|
||||
count > 0 &&
|
||||
phase === "browse" &&
|
||||
(destination === 0
|
||||
? local < 0.07
|
||||
: local >= ARRIVAL && local < ARRIVAL + 0.07);
|
||||
return frame;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||
|
||||
describe("notebook choreography", () => {
|
||||
it("opens and boots before browsing, then blanks the display before closing", () => {
|
||||
const frames = Array.from({ length: 1001 }, (_, index) =>
|
||||
getNotebookFrame(index / 1000, 4),
|
||||
);
|
||||
const phases = [...new Set(frames.map((frame) => frame.phase))];
|
||||
expect(phases).toEqual([
|
||||
"approach",
|
||||
"open",
|
||||
"boot",
|
||||
"cursor-in",
|
||||
"browse",
|
||||
"hold",
|
||||
"close",
|
||||
]);
|
||||
for (const frame of frames) {
|
||||
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
|
||||
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
|
||||
if (frame.phase === "close" && frame.lidAngle < 112)
|
||||
expect(frame.screenOpacity).toBe(0);
|
||||
}
|
||||
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
|
||||
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the previous project until cursor arrival, including reverse scroll", () => {
|
||||
const frames = Array.from({ length: 10001 }, (_, index) =>
|
||||
getNotebookFrame(index / 10000, 4),
|
||||
);
|
||||
for (let selected = 1; selected < 4; selected += 1) {
|
||||
const arrival = frames.findIndex(
|
||||
(frame) => frame.activeTabIndex === selected,
|
||||
);
|
||||
const before = getNotebookFrame((arrival - 1) / 10000, 4);
|
||||
const atClick = frames[arrival];
|
||||
expect(before.activeTabIndex).toBe(selected - 1);
|
||||
expect(before.cursorClicking).toBe(false);
|
||||
expect(atClick.cursorClicking).toBe(true);
|
||||
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
|
||||
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
|
||||
expect(reversed.activeTabIndex).toBe(selected - 1);
|
||||
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
|
||||
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeks directly into a readable hold for the requested project", () => {
|
||||
for (const count of [1, 4]) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const progress = getNotebookSeekProgress(index, count);
|
||||
const frame = getNotebookFrame(progress, count);
|
||||
expect(frame.activeTabIndex).toBe(index);
|
||||
expect(frame.screenOpacity).toBe(1);
|
||||
expect(frame.cursorClicking).toBe(false);
|
||||
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
|
||||
index,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("does not expose a tab or cursor for empty or invalid project counts", () => {
|
||||
expect(getNotebookFrame(0.6, 0)).toMatchObject({
|
||||
activeTabIndex: -1,
|
||||
cursorVisible: false,
|
||||
cursorClicking: false,
|
||||
});
|
||||
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,30 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { metadata as rootMetadata } from "../layout";
|
||||
import CurriculoPage from "./page";
|
||||
|
||||
jest.mock("next/font/google", () => ({
|
||||
Geist_Mono: () => ({ variable: "font-geist-mono" }),
|
||||
Space_Grotesk: () => ({ variable: "font-space-grotesk" }),
|
||||
}));
|
||||
|
||||
jest.mock("next/font/local", () => () => ({
|
||||
variable: "font-monument-extended",
|
||||
}));
|
||||
|
||||
const authorizedRegion =
|
||||
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
|
||||
|
||||
it("uses one factual regional description across home metadata", () => {
|
||||
const description = rootMetadata.description;
|
||||
expect(typeof description).toBe("string");
|
||||
expect(description).toContain(authorizedRegion);
|
||||
expect(description).not.toMatch(
|
||||
/Joinville|São Francisco do Sul|Itapoá|Jaraguá do Sul|Curitiba/,
|
||||
);
|
||||
expect(rootMetadata.openGraph).toMatchObject({ description });
|
||||
expect(rootMetadata.twitter).toMatchObject({ description });
|
||||
});
|
||||
|
||||
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
|
||||
render(CurriculoPage());
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import { siteName, siteUrl } from "@/lib/site-content";
|
||||
const pageUrl = `${siteUrl}/curriculo`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` },
|
||||
title: "Currículo de Evandro Fonseca Junior para Contratação",
|
||||
description:
|
||||
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
|
||||
alternates: { canonical: pageUrl },
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
|
||||
description:
|
||||
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
+711
-568
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title"><title>Evandro Fonseca Junior</title><rect width="64" height="64" fill="#0a0a0a"/><path fill="#ccff00" d="M12 12h40v8H21v8h25v8H21v8h31v8H12z"/><path fill="#f4f2ed" d="M22 24h22v8H22z"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
+4
-26
@@ -10,9 +10,9 @@ import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
import "./globals.css";
|
||||
|
||||
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br";
|
||||
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
|
||||
const seoDescription =
|
||||
"Sistemas web, aplicativos e integrações sob medida, do negócio à produção, com Evandro Fonseca Junior. Base em Santa Catarina, atendimento remoto.";
|
||||
"Evandro Fonseca Junior é engenheiro da computação e desenvolvedor de software com atuação em Next.js, TypeScript, Rust, IoT e integrações. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
@@ -77,7 +77,7 @@ export const metadata: Metadata = {
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||
alt: "Evandro Fonseca Junior | Engenharia de Software, Next.js, Tauri e Rust",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const metadata: Metadata = {
|
||||
images: [
|
||||
{
|
||||
url: "/opengraph-image",
|
||||
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
|
||||
alt: "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -128,28 +128,6 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
lang="pt-BR"
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
>
|
||||
<head>
|
||||
{/* Set arrival policy before native restoration and client-side layout changes. */}
|
||||
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style>
|
||||
<script>{`
|
||||
addEventListener("load", () => {
|
||||
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
|
||||
if (sheet) sheet.disabled = true;
|
||||
}, { once: true });
|
||||
if (location.pathname === "/") {
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
if (navigation?.type !== "back_forward") {
|
||||
history.scrollRestoration = "manual";
|
||||
if (navigation?.type === "reload" && location.hash) {
|
||||
history.replaceState(history.state, "", location.pathname + location.search);
|
||||
}
|
||||
addEventListener("pagehide", () => {
|
||||
history.scrollRestoration = "auto";
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
`}</script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function shouldLoadHeroScene({
|
||||
reducedMotion,
|
||||
coarsePointer,
|
||||
width,
|
||||
}: {
|
||||
reducedMotion: boolean;
|
||||
coarsePointer: boolean;
|
||||
width: number;
|
||||
}) {
|
||||
return !reducedMotion && !coarsePointer && width >= 1024;
|
||||
}
|
||||
+9
-9
@@ -2,28 +2,28 @@ import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Evandro.dev.br — Sistemas sob medida para empresas",
|
||||
name: "Evandro Fonseca Junior — Engenheiro da Computação e Desenvolvedor de Software",
|
||||
short_name: "Evandro.dev",
|
||||
description:
|
||||
"Desenvolvimento de sistemas web, aplicativos e integrações sob medida. Engenharia de software com Evandro Fonseca Junior, de Santa Catarina para sua empresa.",
|
||||
"Portfólio profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#000000",
|
||||
theme_color: "#000000",
|
||||
orientation: "portrait-primary",
|
||||
categories: ["business", "productivity", "technology"],
|
||||
categories: ["portfolio", "developer", "engineering", "technology"],
|
||||
lang: "pt-BR",
|
||||
dir: "ltr",
|
||||
icons: [
|
||||
{
|
||||
src: "/brand/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
src: "/favicon.ico",
|
||||
sizes: "any",
|
||||
type: "image/x-icon",
|
||||
},
|
||||
{
|
||||
src: "/icon.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
|
||||
export const alt = "Evandro.dev.br — Sistemas sob medida para empresas";
|
||||
export const alt =
|
||||
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = OG_CONTENT_TYPE;
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow="Engenharia de software para empresas"
|
||||
title="Sistemas sob medida."
|
||||
description="Da ideia à produção. Sistemas web, aplicativos e integrações com Evandro Fonseca Junior."
|
||||
eyebrow="Portfólio de engenharia de software"
|
||||
title="Evandro Fonseca Junior"
|
||||
description="Engenheiro da Computação e Desenvolvedor de Software"
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
|
||||
+1
-16
@@ -1,25 +1,10 @@
|
||||
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
|
||||
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
|
||||
function yearsOfExperience() {
|
||||
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const projects = await loadFeaturedProjects();
|
||||
return (
|
||||
<CinematicHome
|
||||
heroStats={{
|
||||
years: yearsOfExperience(),
|
||||
projects: projects.length,
|
||||
demos: projects.filter(
|
||||
(project) =>
|
||||
project.demoUrl && project.demoAvailability !== "unavailable",
|
||||
).length,
|
||||
}}
|
||||
projects={projects}
|
||||
projects={await loadFeaturedProjects()}
|
||||
experiences={portfolio.experiences}
|
||||
skillCategories={portfolio.skills.technical}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import PrivacyPage, { metadata } from "./page";
|
||||
|
||||
it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () => {
|
||||
it("publishes factual privacy content and a breadcrumb-only JSON-LD graph", () => {
|
||||
const { container } = render(<PrivacyPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: /privacidade/i }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(/evandro\.analytics-consent\.v1/)).toBeVisible();
|
||||
expect(screen.getAllByText(/WhatsApp/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/nenhuma tag do Google é carregada/i)).toBeVisible();
|
||||
expect(screen.getByText(/2026-09-04/)).toBeVisible();
|
||||
|
||||
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||
|
||||
@@ -12,7 +12,6 @@ export const metadata: Metadata = {
|
||||
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
|
||||
alternates: { canonical: pageUrl },
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: `Privacidade e consentimento | ${siteName}`,
|
||||
description:
|
||||
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
|
||||
@@ -108,26 +107,10 @@ export default function PrivacyPage() {
|
||||
<code>granted</code> ou <code>denied</code>.
|
||||
</p>
|
||||
<p className="ds-copy mt-sp-4">
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -158,7 +141,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-13">2026-09-13</time>.
|
||||
<time dateTime="2026-09-04">2026-09-04</time>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,13 +104,12 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
||||
expect(screen.getByText(detail)).not.toBeVisible();
|
||||
}
|
||||
|
||||
const primaryCtas = screen.getAllByRole("link", { name: /WhatsApp/i });
|
||||
const primaryCtas = screen.getAllByRole("link", {
|
||||
name: service.ctaLabel,
|
||||
});
|
||||
expect(primaryCtas).toHaveLength(2);
|
||||
for (const cta of primaryCtas) {
|
||||
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"));
|
||||
expect(cta).toHaveAttribute("href", "/#contact");
|
||||
}
|
||||
|
||||
const expectedCaseLinks = service.relatedCaseIds
|
||||
@@ -210,6 +209,18 @@ 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,7 +3,6 @@ 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 {
|
||||
@@ -45,7 +44,6 @@ export async function generateMetadata({
|
||||
canonical: url,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: service.seoTitle,
|
||||
description: service.metaDescription,
|
||||
url,
|
||||
@@ -124,13 +122,14 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
{service.answer}
|
||||
</p>
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
<WhatsAppLink
|
||||
service={service.navLabel}
|
||||
placement="service_detail_hero"
|
||||
className="ds-link ds-link-accent min-h-12"
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.ctaHref}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{service.ctaLabel} no WhatsApp
|
||||
</WhatsAppLink>
|
||||
{service.ctaLabel}
|
||||
</MagneticButton>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
@@ -140,11 +139,6 @@ 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>
|
||||
@@ -311,13 +305,14 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
remotamente.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<WhatsAppLink
|
||||
service={service.navLabel}
|
||||
placement="service_detail_footer"
|
||||
className="ds-link ds-link-accent min-h-12"
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.ctaHref}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{service.ctaLabel} no WhatsApp
|
||||
</WhatsAppLink>
|
||||
{service.ctaLabel}
|
||||
</MagneticButton>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/servicos"
|
||||
|
||||
+76
-261
@@ -1,53 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList } from "@/lib/seo";
|
||||
import {
|
||||
caseStudies,
|
||||
serviceHubLinks,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/servicos`;
|
||||
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida";
|
||||
const pageTitle = "Sites, Sistemas Web e Aplicativos Mobile e Desktop";
|
||||
const pageDescription =
|
||||
"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),
|
||||
);
|
||||
"Desenvolvimento de sites, sistemas web, aplicativos mobile e desktop para Windows e Linux. Integrações, melhorias e consultoria para o seu negócio.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: pageTitle,
|
||||
@@ -56,7 +17,6 @@ export const metadata: Metadata = {
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
@@ -96,234 +56,89 @@ export default function ServicesIndexPage() {
|
||||
<main>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
<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}
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section">
|
||||
<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>
|
||||
<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
|
||||
<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 items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-sp-8"
|
||||
className="grid gap-sp-4 py-sp-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] 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>
|
||||
<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>
|
||||
</li>
|
||||
</article>
|
||||
))}
|
||||
</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>
|
||||
<div className="mt-sp-8 max-w-[72ch]">
|
||||
<h2 className="ds-card-title">
|
||||
Ainda não sabe qual caminho seguir?
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+21
-14
@@ -1,33 +1,40 @@
|
||||
{
|
||||
"checkedAt": "2026-07-11T05:31:16.931Z",
|
||||
"checkedAt": "2026-08-31T19:15:15.134Z",
|
||||
"projects": {
|
||||
"sev-modaintima": {
|
||||
"status": 200,
|
||||
"contentType": "text/html; charset=utf-8",
|
||||
"checkedAt": "2026-07-11T05:31:16.931Z",
|
||||
"checkedAt": "2026-08-31T19:15:15.134Z",
|
||||
"finalUrl": "https://sevmodaintima.com.br/",
|
||||
"latencyMs": 248
|
||||
"latencyMs": 2768
|
||||
},
|
||||
"nutriscan": {
|
||||
"status": null,
|
||||
"contentType": null,
|
||||
"checkedAt": "2026-07-11T05:31:16.931Z",
|
||||
"latencyMs": 55,
|
||||
"error": "fetch failed"
|
||||
"status": 200,
|
||||
"contentType": "text/html; charset=utf-8",
|
||||
"checkedAt": "2026-08-31T19:15:15.134Z",
|
||||
"finalUrl": "https://nutriscan.evandro.dev.br/",
|
||||
"latencyMs": 2538
|
||||
},
|
||||
"twins-lab": {
|
||||
"status": 404,
|
||||
"contentType": "text/plain; charset=utf-8",
|
||||
"checkedAt": "2026-07-11T05:31:16.931Z",
|
||||
"checkedAt": "2026-08-31T19:15:15.134Z",
|
||||
"finalUrl": "https://twins.evandro.dev.br/",
|
||||
"latencyMs": 75
|
||||
"latencyMs": 2541
|
||||
},
|
||||
"sc-plus": {
|
||||
"status": 502,
|
||||
"contentType": "text/html; charset=UTF-8",
|
||||
"checkedAt": "2026-07-11T05:31:16.931Z",
|
||||
"status": 200,
|
||||
"contentType": "text/html; charset=utf-8",
|
||||
"checkedAt": "2026-08-31T19:15:15.134Z",
|
||||
"finalUrl": "https://scplus.evandro.dev.br/",
|
||||
"latencyMs": 79
|
||||
"latencyMs": 2576
|
||||
},
|
||||
"as-servicos": {
|
||||
"status": 200,
|
||||
"contentType": "text/html; charset=utf-8",
|
||||
"checkedAt": "2026-08-31T19:15:15.134Z",
|
||||
"finalUrl": "https://asservicos.com/",
|
||||
"latencyMs": 2592
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
Este é um runbook declarativo para um operador autorizado. Ele descreve evidências que deverão ser coletadas em contas e painéis externos; não registra execução, acesso, configuração, medição ou resultado externo. Search Console, Bing Webmaster Tools, Cloudflare, CrUX e RUM são próximas ações externas, não evidência do repositório.
|
||||
|
||||
Medições executadas e o auditor repetível estão em `docs/seo-geo-validation.md`.
|
||||
|
||||
## Regras de operação
|
||||
|
||||
- Executar cada ação somente com autorização e na propriedade correta.
|
||||
|
||||
+826
@@ -0,0 +1,826 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@theme {
|
||||
/* Literal values only: var() indirection here stops Tailwind v4 from generating *-sp-* utilities. */
|
||||
--spacing-sp-1: 4px;
|
||||
--spacing-sp-2: 8px;
|
||||
--spacing-sp-3: 12px;
|
||||
--spacing-sp-4: 16px;
|
||||
--spacing-sp-5: 20px;
|
||||
--spacing-sp-6: 24px;
|
||||
--spacing-sp-8: 32px;
|
||||
--spacing-sp-10: 40px;
|
||||
--spacing-sp-12: 48px;
|
||||
--spacing-sp-16: 64px;
|
||||
--spacing-sp-20: 80px;
|
||||
--spacing-sp-24: 96px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #0a0a0a;
|
||||
--ink-80: rgba(10, 10, 10, 0.8);
|
||||
--ink-40: rgba(10, 10, 10, 0.4);
|
||||
--ink-12: rgba(10, 10, 10, 0.12);
|
||||
--ink-06: rgba(10, 10, 10, 0.06);
|
||||
--paper: #f4f2ed;
|
||||
--paper-dark: #eceae4;
|
||||
--white: #ffffff;
|
||||
--slash: #ccff00;
|
||||
--slash-dim: rgba(204, 255, 0, 0.16);
|
||||
--meta: #4a4a4a;
|
||||
--border: rgba(10, 10, 10, 0.1);
|
||||
--border-light: rgba(10, 10, 10, 0.06);
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-8: 32px;
|
||||
--sp-10: 40px;
|
||||
--sp-12: 48px;
|
||||
--sp-16: 64px;
|
||||
--sp-20: 80px;
|
||||
--sp-24: 96px;
|
||||
--r-sm: 4px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 16px;
|
||||
--r-full: 999px;
|
||||
--font-display: var(--font-space-grotesk), sans-serif;
|
||||
--font-hero: var(--font-monument-extended), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
/* Layered so Tailwind utilities (later layer) can override the zero-reset. */
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 68px;
|
||||
}
|
||||
|
||||
html [id] {
|
||||
scroll-margin-top: 68px;
|
||||
}
|
||||
|
||||
.site-header-nav,
|
||||
.site-header-cv,
|
||||
.site-header-version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle,
|
||||
.site-header-mobile-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.site-header-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.site-header-cv,
|
||||
.site-header-version {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle,
|
||||
.site-header-mobile-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 16px;
|
||||
z-index: 10000;
|
||||
transform: translateY(-160%);
|
||||
border: 1px solid var(--slash);
|
||||
background: var(--ink);
|
||||
color: var(--white);
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
||||
.film-grain {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.68;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@supports (overflow: clip) {
|
||||
html,
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--slash);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(10, 10, 10, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(10, 10, 10, 0.03) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.bg-grid-light {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
}
|
||||
|
||||
.ds-main {
|
||||
/* no top padding needed */
|
||||
}
|
||||
|
||||
.ds-section {
|
||||
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 600px;
|
||||
}
|
||||
|
||||
.ds-section:nth-of-type(even):not(.ds-section-dark) {
|
||||
background: var(--paper-dark);
|
||||
}
|
||||
|
||||
.ds-section-dark {
|
||||
background: var(--ink);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.ds-section-shell,
|
||||
.ds-loading,
|
||||
.ds-error {
|
||||
width: min(1240px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ds-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-6);
|
||||
margin-bottom: var(--sp-10);
|
||||
}
|
||||
|
||||
.ds-section-meta {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.ds-eyebrow,
|
||||
.ds-kicker {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--meta);
|
||||
}
|
||||
|
||||
.ds-eyebrow {
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.ds-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(24px, 4vw, 44px);
|
||||
line-height: 1.02;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.ds-section-description {
|
||||
max-width: 58ch;
|
||||
color: rgba(10, 10, 10, 0.74);
|
||||
font-size: 14px;
|
||||
line-height: 1.82;
|
||||
}
|
||||
|
||||
.ds-grid-2,
|
||||
.ds-grid-4 {
|
||||
display: grid;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
.ds-grid-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ds-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ds-card,
|
||||
.ds-panel {
|
||||
border: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ds-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: clamp(20px, 3vw, 28px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ds-panel {
|
||||
background: rgba(10, 10, 10, 0.03);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ds-card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(16px, 2.4vw, 30px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.025em;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.ds-copy {
|
||||
margin-top: var(--sp-4);
|
||||
color: var(--ink-80);
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ds-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-6);
|
||||
}
|
||||
|
||||
.ds-tag {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(10, 10, 10, 0.12);
|
||||
background: rgba(10, 10, 10, 0.03);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
/* pílula inteira numa linha: a quebra acontece entre tags (.ds-tags wrap), nunca dentro dela */
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ds-tag:hover {
|
||||
border-color: rgba(10, 10, 10, 0.3);
|
||||
box-shadow: 0 2px 12px rgba(10, 10, 10, 0.1);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-tag:hover {
|
||||
border-color: rgba(204, 255, 0, 0.3);
|
||||
box-shadow: 0 2px 16px rgba(204, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.ds-metric {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: rgba(10, 10, 10, 0.65);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ds-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ds-link:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ds-link-accent {
|
||||
color: var(--ink);
|
||||
background: var(--slash);
|
||||
padding: 12px 18px;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
background 0.25s ease;
|
||||
}
|
||||
|
||||
.ds-link-accent:hover {
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 0 20px rgba(204, 255, 0, 0.45),
|
||||
0 0 48px rgba(204, 255, 0, 0.18);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-link-accent:hover {
|
||||
box-shadow:
|
||||
0 0 24px rgba(204, 255, 0, 0.55),
|
||||
0 0 60px rgba(204, 255, 0, 0.22);
|
||||
}
|
||||
|
||||
.ds-link-muted {
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--border);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.ds-link-muted:hover {
|
||||
border-color: var(--ink-40);
|
||||
box-shadow: 0 4px 20px rgba(10, 10, 10, 0.12);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-link-muted:hover {
|
||||
border-color: rgba(204, 255, 0, 0.35);
|
||||
box-shadow: 0 4px 20px rgba(204, 255, 0, 0.08);
|
||||
}
|
||||
|
||||
.ds-list {
|
||||
margin-top: var(--sp-5);
|
||||
padding-left: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.85;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ds-list li::marker {
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
.ds-loading,
|
||||
.ds-error {
|
||||
padding: var(--sp-10) clamp(24px, 6vw, 64px);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-eyebrow,
|
||||
.ds-section-dark .ds-kicker,
|
||||
.ds-section-dark .ds-metric {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-section-description {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-card,
|
||||
.ds-section-dark .ds-panel {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-copy,
|
||||
.ds-section-dark .ds-list {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-tag {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-link-muted {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.ds-draw-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin-top: var(--sp-4);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(10, 10, 10, 0.38) 0%,
|
||||
rgba(10, 10, 10, 0.06) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-draw-line {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(204, 255, 0, 0.5) 0%,
|
||||
rgba(204, 255, 0, 0.08) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes ds-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-reverse {
|
||||
animation: marquee-reverse 45s linear infinite;
|
||||
}
|
||||
|
||||
.ds-skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ds-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.ds-skeleton::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, #111 0%, #1f1f1f 50%, #111 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: ds-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.ds-skeleton-kicker {
|
||||
height: 10px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.ds-skeleton-title {
|
||||
height: 26px;
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.ds-skeleton-line {
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-skeleton-line-short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
|
||||
animation-duration: 0.01ms !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
|
||||
animation-iteration-count: 1 !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component transition styles. */
|
||||
transition-duration: 0.01ms !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override smooth scrolling. */
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.site-header-nav,
|
||||
.site-header-cv,
|
||||
.site-header-version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle,
|
||||
.site-header-mobile-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ds-section-header {
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.ds-grid-2,
|
||||
.ds-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ds-section {
|
||||
padding: 72px 20px;
|
||||
}
|
||||
|
||||
.ds-section-title {
|
||||
font-size: clamp(24px, 8vw, 34px);
|
||||
}
|
||||
|
||||
.ds-card-title {
|
||||
font-size: clamp(16px, 5vw, 24px);
|
||||
}
|
||||
|
||||
.ds-section-description,
|
||||
.ds-copy,
|
||||
.ds-list {
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.ds-tags {
|
||||
gap: 8px;
|
||||
padding-top: var(--sp-5);
|
||||
}
|
||||
|
||||
.ds-tag {
|
||||
font-size: 10px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
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% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseDot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: scale(2);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cinematic card hover effect */
|
||||
.ds-card {
|
||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ds-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(10, 10, 10, 0.12);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-card:hover {
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.4),
|
||||
0 0 1px rgba(204, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Cinematic section reveal transforms */
|
||||
.cinematic-reveal {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const brandMark = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/brand/mark-dark.png"),
|
||||
).toString("base64")}`;
|
||||
|
||||
export const OG_SIZE = { width: 1200, height: 630 } as const;
|
||||
export const OG_CONTENT_TYPE = "image/png";
|
||||
|
||||
@@ -23,7 +16,6 @@ export function OgCard({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
background: "#050505",
|
||||
@@ -32,17 +24,9 @@ export function OgCard({
|
||||
fontFamily: "Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={brandMark}
|
||||
alt="Evandro.dev.br"
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ position: "absolute", top: 48, right: 72 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
paddingRight: 112,
|
||||
color: "#ccff00",
|
||||
fontSize: 24,
|
||||
letterSpacing: "0.16em",
|
||||
|
||||
@@ -86,27 +86,6 @@ describe("factual portfolio records", () => {
|
||||
/produç|clientes?|leads?|ranking|performance|resultado comercial|anime\.js|shadcn/i,
|
||||
);
|
||||
});
|
||||
it("keeps Reddit Posts Explorer and MinGW GCC Installer within what their public repositories support", () => {
|
||||
const reddit = serializeProject("reddit-explorer");
|
||||
const gcc = serializeProject("gcc-installer");
|
||||
|
||||
expect(reddit).toContain("MongoDB");
|
||||
expect(reddit).not.toMatch(
|
||||
/SSG|rate limit|cache|tempo real|real-time|busca em|otimiza|responsiv/i,
|
||||
);
|
||||
expect(gcc).toContain("mingw64");
|
||||
expect(gcc).not.toMatch(
|
||||
/assíncron|monitoramento|drastic|Watchdog|dependências|intuitiv/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not claim measured performance for SC Plus without a stored measurement", () => {
|
||||
// Lighthouse mobile local: LCP 3,7 s em 2026-10-05; a alegação anterior (LCP < 2,5 s) não se sustentava
|
||||
expect(serializeProject("sc-plus")).not.toMatch(
|
||||
/LCP|Core Web Vitals|otimizad|velocidade|rápid|Performance/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not publish the unavailable Twins Lab demo", () => {
|
||||
expect(
|
||||
portfolio.projects.find((item) => item.id === "twins-lab")?.demoUrl,
|
||||
|
||||
+1
-12
@@ -37,17 +37,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
|
||||
["@id", "@type", "inLanguage", "name", "publisher", "url"].sort(),
|
||||
);
|
||||
expect(Object.keys(person).sort()).toEqual(
|
||||
[
|
||||
"@id",
|
||||
"@type",
|
||||
"homeLocation",
|
||||
"image",
|
||||
"jobTitle",
|
||||
"knowsAbout",
|
||||
"name",
|
||||
"sameAs",
|
||||
"url",
|
||||
].sort(),
|
||||
["@id", "@type", "homeLocation", "image", "name", "sameAs", "url"].sort(),
|
||||
);
|
||||
expect(website).toMatchObject({
|
||||
"@type": "WebSite",
|
||||
@@ -59,7 +49,6 @@ it("keeps the root graph limited to verified website and person fields", () => {
|
||||
"@type": "Person",
|
||||
"@id": SEO_IDS.person,
|
||||
image: `${siteUrl}/edev.jpg`,
|
||||
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
|
||||
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
|
||||
sameAs: [
|
||||
"https://github.com/evandrodevbr",
|
||||
|
||||
-12
@@ -24,18 +24,6 @@ export function buildPersonJsonLd() {
|
||||
name: "Evandro Fonseca Junior",
|
||||
url: siteUrl,
|
||||
image: `${siteUrl}/edev.jpg`,
|
||||
// cargo e áreas vêm de public/portfolio-data.json e estão visíveis em /curriculo e na seção de skills
|
||||
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
|
||||
knowsAbout: [
|
||||
"Next.js",
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"IoT",
|
||||
"Computação em nuvem",
|
||||
"Inteligência artificial",
|
||||
],
|
||||
homeLocation: {
|
||||
"@type": "Place",
|
||||
name: "Garuva, Santa Catarina, Brasil",
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { caseStudies, getRelatedServices, servicePages } from "./site-content";
|
||||
|
||||
const detailedIds = [
|
||||
"as-servicos",
|
||||
"gcc-installer",
|
||||
"nutriscan",
|
||||
"reddit-explorer",
|
||||
"twins-lab",
|
||||
] as const;
|
||||
const detailedIds = ["as-servicos", "nutriscan", "twins-lab"] as const;
|
||||
const immutableGitHubBlobUrl =
|
||||
/^https:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[0-9a-f]{40}\/.+$/;
|
||||
|
||||
@@ -17,7 +11,7 @@ function getDetail(id: (typeof detailedIds)[number]) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
it("limits detailed content to the evidenced cases (public repositories pinned to a commit)", () => {
|
||||
it("limits detailed content to the three evidenced cases", () => {
|
||||
expect(
|
||||
caseStudies
|
||||
.filter(({ detail }) => detail !== undefined)
|
||||
@@ -70,15 +64,6 @@ it("requires sources for factual statements and resolves every source ID", () =>
|
||||
expect(getDetail("nutriscan").boundaries[0]?.sourceIds).toContain(
|
||||
"nutri-readme",
|
||||
);
|
||||
|
||||
// reddit-explorer e gcc-installer: o 1º limite vem da documentação do repositório; o 2º é o escopo editorial (sem fonte)
|
||||
for (const id of ["reddit-explorer", "gcc-installer"] as const) {
|
||||
const { boundaries } = getDetail(id);
|
||||
expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives the exact related service set from the existing relationship", () => {
|
||||
@@ -111,14 +96,3 @@ it("does not publish the unavailable Twins Lab demo", () => {
|
||||
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps AS Serviços as a project (code only) and states the author's participation", () => {
|
||||
const asServicos = caseStudies.find(({ id }) => id === "as-servicos");
|
||||
expect(asServicos?.demoUrl).toBeNull();
|
||||
expect(asServicos?.githubUrl).toBe(
|
||||
"https://github.com/evandrodevbr/as-servicos",
|
||||
);
|
||||
expect(asServicos?.role).toMatch(/primeira landing page/i);
|
||||
expect(asServicos?.role).toMatch(/animações 3D/i);
|
||||
expect(asServicos?.role).toMatch(/painel para controle de projetos/i);
|
||||
});
|
||||
+22
-136
@@ -2,13 +2,13 @@ export const siteUrl = "https://evandro.dev.br";
|
||||
export const siteName = "Evandro.dev.br";
|
||||
|
||||
export const intentCtas = {
|
||||
project: {
|
||||
label: "Conversar sobre meu projeto",
|
||||
href: "/#contact",
|
||||
hiring: {
|
||||
label: "Avaliar perfil para contratação",
|
||||
href: "/curriculo",
|
||||
},
|
||||
consulting: {
|
||||
label: "Conhecer as soluções",
|
||||
href: "/servicos",
|
||||
label: "Solicitar consultoria técnica",
|
||||
href: "/#contact",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -75,8 +75,6 @@ export interface CaseStudyContent {
|
||||
stack: string[];
|
||||
demoUrl?: string | null;
|
||||
githubUrl?: string | null;
|
||||
/** Papel do autor quando o projeto não é só dele (texto editorial, sem fonte pública). */
|
||||
role?: string;
|
||||
detail?: CaseStudyDetail;
|
||||
}
|
||||
|
||||
@@ -146,7 +144,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "engenharia-de-software",
|
||||
navLabel: "Sistemas web e aplicativos mobile",
|
||||
eyebrow: "Engenharia de software",
|
||||
seoTitle: "Sistemas Web e Aplicativos Mobile sob Medida",
|
||||
seoTitle: "Desenvolvimento de Sistemas Web e Aplicativos Mobile",
|
||||
metaDescription:
|
||||
"Sistemas web e aplicativos mobile sob medida, integração entre ferramentas e evolução de software existente. Escopo, plataformas e entregas definidos por projeto.",
|
||||
headline: "Sistemas e apps para o seu trabalho.",
|
||||
@@ -268,7 +266,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "tauri-rust",
|
||||
navLabel: "Aplicativos desktop para Windows e Linux",
|
||||
eyebrow: "Aplicativos desktop",
|
||||
seoTitle: "Aplicativos Desktop com Tauri e Rust",
|
||||
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
|
||||
metaDescription:
|
||||
"Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.",
|
||||
headline: "Aplicativos para Windows e Linux.",
|
||||
@@ -620,83 +618,22 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "reddit-explorer",
|
||||
title: "Reddit Posts Explorer",
|
||||
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
subtitle: "Aplicação orientada a dados com API, cache e busca",
|
||||
summary:
|
||||
"Aplicação Astro SSR que coleta os posts em alta do r/artificial, grava no MongoDB sem duplicar e filtra por período, upvotes ou comentários.",
|
||||
"Projeto em Astro e MongoDB para explorar conteúdo com filtros, ordenação e cache, mantendo boa experiência em consultas frequentes.",
|
||||
impact:
|
||||
"Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
|
||||
stack: ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
|
||||
"Mostra capacidade de desenhar consumo de API, camada de persistência e experiência de busca sem sobrecarregar a aplicação.",
|
||||
stack: ["Astro.js", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
|
||||
githubUrl: "https://github.com/evandrodevbr/reddit-search",
|
||||
detail: {
|
||||
answer: {
|
||||
text: "Aplicação Astro em modo SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
|
||||
sourceIds: ["rd-readme", "rd-service", "rd-astro"],
|
||||
},
|
||||
challenge: {
|
||||
text: "Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
|
||||
sourceIds: ["rd-service", "rd-filter"],
|
||||
},
|
||||
implementation: [
|
||||
{
|
||||
text: "O serviço consulta r/artificial/hot.json com User-Agent identificado e limite de 15 segundos, e grava os posts em lote com upsert pelo id; rodar a coleta de novo atualiza os existentes.",
|
||||
sourceIds: ["rd-service"],
|
||||
},
|
||||
{
|
||||
text: "O endpoint de filtro valida as datas e o campo de ordenação (ups ou num_comments) e responde 400 quando faltam parâmetros ou eles são inválidos.",
|
||||
sourceIds: ["rd-filter"],
|
||||
},
|
||||
{
|
||||
text: "O Astro roda com output server e o adapter Node em modo standalone.",
|
||||
sourceIds: ["rd-astro", "rd-package"],
|
||||
},
|
||||
],
|
||||
boundaries: [
|
||||
{
|
||||
text: "O subreddit é fixo, e o projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
|
||||
sourceIds: ["rd-readme"],
|
||||
},
|
||||
{
|
||||
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, volume de dados ou resultado comercial.",
|
||||
sourceIds: [],
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "rd-readme",
|
||||
label: "README do projeto",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/README.md",
|
||||
},
|
||||
{
|
||||
id: "rd-service",
|
||||
label: "Serviço de coleta e consulta",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/lib/services/redditService.ts",
|
||||
},
|
||||
{
|
||||
id: "rd-filter",
|
||||
label: "Endpoint de filtro",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/pages/api/posts/filter.ts",
|
||||
},
|
||||
{
|
||||
id: "rd-astro",
|
||||
label: "Configuração do Astro",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/astro.config.mjs",
|
||||
},
|
||||
{
|
||||
id: "rd-package",
|
||||
label: "Manifesto do projeto",
|
||||
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/package.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sc-plus",
|
||||
title: "SC Plus Aquicultura",
|
||||
subtitle: "Landing page institucional em Next.js 15",
|
||||
subtitle: "Site institucional em Next.js com foco em performance e SEO",
|
||||
summary:
|
||||
"Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
|
||||
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura técnica de indexação.",
|
||||
impact:
|
||||
"Dar à instituição uma página própria, com base técnica para ser encontrada em buscadores.",
|
||||
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO técnico trabalham juntos.",
|
||||
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
|
||||
demoUrl: "https://scplus.evandro.dev.br",
|
||||
githubUrl: "https://github.com/evandrodevbr/scplus",
|
||||
@@ -704,80 +641,29 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "gcc-installer",
|
||||
title: "MinGW GCC Installer",
|
||||
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
subtitle:
|
||||
"Ferramenta desktop para automação de ambiente de desenvolvimento",
|
||||
summary:
|
||||
"Aplicativo Python com interface tkinter que lista as builds do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e adiciona o GCC ao PATH do usuário.",
|
||||
"Aplicação em Python para simplificar setup local no Windows, com download assíncrono, checagem de compatibilidade e integração com PATH.",
|
||||
impact:
|
||||
"Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
|
||||
stack: ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
|
||||
"Mostra familiaridade com software desktop, integração com sistema operacional e automação de setup técnico.",
|
||||
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
|
||||
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
|
||||
detail: {
|
||||
answer: {
|
||||
text: "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
|
||||
sourceIds: ["gcc-readme", "gcc-main"],
|
||||
},
|
||||
challenge: {
|
||||
text: "Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
|
||||
sourceIds: ["gcc-readme"],
|
||||
},
|
||||
implementation: [
|
||||
{
|
||||
text: "A lista de versões vem da API de releases do repositório niXman/mingw-builds-binaries, e as builds com ucrt e thread model posix são destacadas na tabela.",
|
||||
sourceIds: ["gcc-main"],
|
||||
},
|
||||
{
|
||||
text: "A instalação extrai o pacote com o 7-Zip (caminho padrão fixo) em uma pasta temporária, move o resultado para C:\\mingw64 e renomeia mingw32-make.exe para make.exe.",
|
||||
sourceIds: ["gcc-main"],
|
||||
},
|
||||
{
|
||||
text: "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE; fora do Windows o programa encerra com um aviso.",
|
||||
sourceIds: ["gcc-main"],
|
||||
},
|
||||
],
|
||||
boundaries: [
|
||||
{
|
||||
text: "Funciona só no Windows, o caminho do 7-Zip é fixo e o fluxo da interface não foi exercitado de ponta a ponta no Windows nesta revisão; os 23 testes cobrem a lógica independente de plataforma, e não há CI.",
|
||||
sourceIds: ["gcc-readme", "gcc-tests"],
|
||||
},
|
||||
{
|
||||
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, número de usuários ou resultado comercial.",
|
||||
sourceIds: [],
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "gcc-readme",
|
||||
label: "README do projeto",
|
||||
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/README.md",
|
||||
},
|
||||
{
|
||||
id: "gcc-main",
|
||||
label: "Aplicativo (main.py)",
|
||||
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/main.py",
|
||||
},
|
||||
{
|
||||
id: "gcc-tests",
|
||||
label: "Testes do núcleo",
|
||||
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/tests/test_core.py",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "as-servicos",
|
||||
title: "AS Serviços",
|
||||
subtitle: "Site institucional com cena 3D procedural",
|
||||
summary:
|
||||
"Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
impact:
|
||||
"Representar engenharia física e software na mesma narrativa visual sem depender de um modelo 3D pronto.",
|
||||
stack: ["Next.js 16", "Three.js", "TypeScript"],
|
||||
demoUrl: null,
|
||||
demoUrl: "https://asservicos.com",
|
||||
githubUrl: "https://github.com/evandrodevbr/as-servicos",
|
||||
role: "Desenvolvimento da primeira landing page do site, com animações 3D, e de um painel para controle de projetos.",
|
||||
detail: {
|
||||
answer: {
|
||||
text: "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
text: "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
sourceIds: ["as-readme", "as-package", "as-engine"],
|
||||
},
|
||||
challenge: {
|
||||
|
||||
@@ -2,9 +2,10 @@ export const CV_URL = "/Profile.pdf";
|
||||
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
|
||||
|
||||
export const NAVIGATION_ITEMS = [
|
||||
{ href: "/#modalities", label: "Soluções" },
|
||||
{ href: "/cases", label: "Projetos" },
|
||||
{ href: "/#process", label: "Como trabalho" },
|
||||
{ href: "/#home", label: "Home" },
|
||||
{ href: "/cases", label: "Casos" },
|
||||
{ href: "/servicos", label: "Serviços" },
|
||||
{ href: "/curriculo", label: "Currículo" },
|
||||
{ href: "/#about", label: "Sobre" },
|
||||
{ href: "/#experience", label: "Experiência" },
|
||||
{ href: "/#contact", label: "Contato" },
|
||||
] as const;
|
||||
@@ -13,16 +13,6 @@ 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 }> }>
|
||||
> {
|
||||
|
||||
+4
-8
@@ -11,18 +11,14 @@
|
||||
"@tailwindcss/oxide",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
],
|
||||
"overrides": {
|
||||
"brace-expansion@<1.1.21": "1.1.21",
|
||||
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "biome format --write",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -32,11 +28,11 @@
|
||||
"@react-three/drei": "10.7.7",
|
||||
"@react-three/fiber": "9.5.0",
|
||||
"clsx": "2.1.1",
|
||||
"cobe": "0.6.4",
|
||||
"gsap": "3.13.0",
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-react": "0.542.0",
|
||||
"motion": "12.23.12",
|
||||
"next": "16.3.8",
|
||||
"next": "16.3.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
|
||||
Generated
+68
-78
@@ -4,10 +4,6 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
brace-expansion@<1.1.21: 1.1.21
|
||||
brace-expansion@>=2.0.0 <2.1.7: 2.1.7
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -21,12 +17,12 @@ importers:
|
||||
clsx:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
cobe:
|
||||
specifier: 0.6.4
|
||||
version: 0.6.4
|
||||
gsap:
|
||||
specifier: 3.13.0
|
||||
version: 3.13.0
|
||||
lenis:
|
||||
specifier: ^1.3.26
|
||||
version: 1.3.26([email protected])
|
||||
lucide-react:
|
||||
specifier: 0.542.0
|
||||
version: 0.542.0([email protected])
|
||||
@@ -34,8 +30,8 @@ importers:
|
||||
specifier: 12.23.12
|
||||
version: 12.23.12([email protected]([email protected]))([email protected])
|
||||
next:
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 16.3.0
|
||||
version: 16.3.0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -710,53 +706,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].8':
|
||||
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
|
||||
'@next/[email protected].0':
|
||||
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -814,8 +810,8 @@ packages:
|
||||
'@sinonjs/[email protected]':
|
||||
resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==}
|
||||
|
||||
'@swc/[email protected].23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
'@swc/[email protected].15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
|
||||
@@ -1258,11 +1254,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected].21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
[email protected]8:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
|
||||
@@ -1337,6 +1333,9 @@ 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==}
|
||||
|
||||
@@ -1893,20 +1892,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
react: '>=17.0.0'
|
||||
vue: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2098,8 +2083,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
[email protected].8:
|
||||
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2186,6 +2171,9 @@ 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==}
|
||||
|
||||
@@ -3437,30 +3425,30 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8': {}
|
||||
'@next/[email protected].0': {}
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].8':
|
||||
'@next/[email protected].0':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/[email protected]':
|
||||
@@ -3531,7 +3519,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@swc/[email protected].23':
|
||||
'@swc/[email protected].15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3954,12 +3942,12 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected].21:
|
||||
[email protected]8:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
[email protected].7:
|
||||
[email protected].4:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -4023,6 +4011,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
phenomenon: 1.6.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4783,10 +4775,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
react: 19.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4891,11 +4879,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
brace-expansion: 1.1.18
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.7
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4929,10 +4917,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
[email protected].0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@next/env': 16.3.8
|
||||
'@swc/helpers': 0.5.23
|
||||
'@next/env': 16.3.0
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.11.21
|
||||
caniuse-lite: 1.0.30001810
|
||||
postcss: 8.5.23
|
||||
@@ -4940,14 +4928,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.8
|
||||
'@next/swc-darwin-x64': 16.3.8
|
||||
'@next/swc-linux-arm64-gnu': 16.3.8
|
||||
'@next/swc-linux-arm64-musl': 16.3.8
|
||||
'@next/swc-linux-x64-gnu': 16.3.8
|
||||
'@next/swc-linux-x64-musl': 16.3.8
|
||||
'@next/swc-win32-arm64-msvc': 16.3.8
|
||||
'@next/swc-win32-x64-msvc': 16.3.8
|
||||
'@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
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -5012,6 +5000,8 @@ snapshots:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
+43
-30
@@ -152,29 +152,31 @@
|
||||
{
|
||||
"id": "reddit-explorer",
|
||||
"title": "Reddit Posts Explorer",
|
||||
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
|
||||
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
|
||||
"subtitle": "Análise de Conteúdo IA",
|
||||
"description": "Aplicação Astro.js com arquitetura SSG otimizada para consumo de Reddit API. Resolve problema de rate limiting através de cache estratégico em MongoDB com Mongoose. Sistema de filtragem por data e ordenação, API REST própria e busca em tempo real com otimização de queries.",
|
||||
"longDescription": "Aplicação web que consome a API do Reddit para buscar, filtrar e analisar posts relacionados à Inteligência Artificial, oferecendo interface intuitiva para descoberta de conteúdo relevante.",
|
||||
"technologies": [
|
||||
"Astro",
|
||||
"Astro.js",
|
||||
"MongoDB",
|
||||
"Mongoose",
|
||||
"TypeScript",
|
||||
"TailwindCSS",
|
||||
"Reddit API"
|
||||
],
|
||||
"features": [
|
||||
"Coleta dos posts em alta do r/artificial",
|
||||
"Upsert por id no MongoDB",
|
||||
"Filtro por período",
|
||||
"Ordenação por upvotes ou comentários",
|
||||
"API própria com validação de parâmetros"
|
||||
"Integração com Reddit API",
|
||||
"Sistema de cache com MongoDB",
|
||||
"Filtragem por data e ordenação",
|
||||
"Interface responsiva",
|
||||
"API REST própria",
|
||||
"Busca em tempo real"
|
||||
],
|
||||
"category": "api",
|
||||
"level": "pleno",
|
||||
"status": "Concluído",
|
||||
"demoUrl": null,
|
||||
"githubUrl": "https://github.com/evandrodevbr/reddit-search",
|
||||
"badges": ["API Integration", "MongoDB", "Código público"],
|
||||
"badges": ["API Integration", "MongoDB", "Real-time"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 3,
|
||||
@@ -184,9 +186,9 @@
|
||||
{
|
||||
"id": "sc-plus",
|
||||
"title": "SC Plus Aquicultura",
|
||||
"subtitle": "Landing page institucional em Next.js 15",
|
||||
"description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
|
||||
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
|
||||
"subtitle": "Landing Page Institucional",
|
||||
"description": "Landing page Next.js 15 com React 19 e Tailwind CSS 4. Arquitetura otimizada para Core Web Vitals com LCP < 2.5s através de code splitting e server-side rendering. SEO técnico implementado com meta tags dinâmicas e structured data.",
|
||||
"longDescription": "Site institucional responsivo desenvolvido com as mais recentes tecnologias web, otimizado para performance e SEO, apresentando os serviços e valores da empresa de aquicultura sustentável.",
|
||||
"technologies": [
|
||||
"Next.js 15",
|
||||
"React 19",
|
||||
@@ -194,16 +196,18 @@
|
||||
"TypeScript"
|
||||
],
|
||||
"features": [
|
||||
"Landing page institucional",
|
||||
"Metadados e URL canônica",
|
||||
"Dados estruturados"
|
||||
"Design moderno e responsivo",
|
||||
"Performance otimizada",
|
||||
"SEO otimizado",
|
||||
"Interface intuitiva",
|
||||
"Carregamento rápido"
|
||||
],
|
||||
"category": "web",
|
||||
"level": "pleno",
|
||||
"status": "Concluído",
|
||||
"demoUrl": "https://scplus.evandro.dev.br",
|
||||
"githubUrl": "https://github.com/evandrodevbr/scplus",
|
||||
"badges": ["Landing page", "Next.js 15"],
|
||||
"badges": ["Responsive", "SEO", "Modern Stack"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 2,
|
||||
@@ -213,23 +217,32 @@
|
||||
{
|
||||
"id": "gcc-installer",
|
||||
"title": "MinGW GCC Installer",
|
||||
"subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
"description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
|
||||
"longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Funciona só no Windows e o caminho do 7-Zip é fixo.",
|
||||
"technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
|
||||
"subtitle": "Ferramenta de Desenvolvimento",
|
||||
"description": "Aplicação desktop Python com Tkinter para automação de setup de ambiente GCC no Windows. Resolve problema de configuração manual complexa através de download assíncrono com threading, verificação de compatibilidade via Win32 API e integração automática com PATH do sistema. Utiliza 7-Zip para extração e Watchdog para monitoramento.",
|
||||
"longDescription": "Ferramenta automatizada que simplifica drasticamente o processo de configuração de ambiente de desenvolvimento GCC no Windows, incluindo verificação de compatibilidade, download automático e integração com o sistema.",
|
||||
"technologies": [
|
||||
"Python",
|
||||
"Tkinter",
|
||||
"Requests",
|
||||
"Watchdog",
|
||||
"Win32 API",
|
||||
"7-Zip",
|
||||
"Threading"
|
||||
],
|
||||
"features": [
|
||||
"Lista as builds publicadas do MinGW-w64",
|
||||
"Destaca builds ucrt e posix",
|
||||
"Baixa e instala em C:\\mingw64",
|
||||
"Adiciona o toolchain ao PATH do usuário",
|
||||
"Testes do núcleo (23)"
|
||||
"Interface gráfica intuitiva",
|
||||
"Download assíncrono com progresso",
|
||||
"Verificação de compatibilidade",
|
||||
"Integração automática com PATH",
|
||||
"Monitoramento em tempo real",
|
||||
"Instalação automática de dependências"
|
||||
],
|
||||
"category": "desktop",
|
||||
"level": "pleno",
|
||||
"status": "Concluído",
|
||||
"demoUrl": null,
|
||||
"githubUrl": "https://github.com/evandrodevbr/gcc-installer",
|
||||
"badges": ["Desktop", "Windows", "Código público"],
|
||||
"badges": ["Desktop", "Automation", "System Integration"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 4,
|
||||
@@ -240,7 +253,7 @@
|
||||
"id": "as-servicos",
|
||||
"title": "AS Serviços",
|
||||
"subtitle": "Site Institucional com Cena 3D Procedural",
|
||||
"description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"description": "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, reduced motion, economia de dados, conexão e capacidade do dispositivo.",
|
||||
"technologies": ["Next.js 16", "TypeScript", "Three.js"],
|
||||
"features": [
|
||||
@@ -251,13 +264,13 @@
|
||||
"category": "web",
|
||||
"level": "senior",
|
||||
"status": "Código público",
|
||||
"demoUrl": null,
|
||||
"demoUrl": "https://asservicos.com",
|
||||
"githubUrl": "https://github.com/evandrodevbr/as-servicos",
|
||||
"badges": ["Three.js", "WebGL", "Código público"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 5,
|
||||
"teamSize": "Em equipe"
|
||||
"teamSize": "Individual"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user