feat: harden portfolio and add SEO foundations
This commit is contained in:
1 parent
b079afdd73
commit
62bcfbd8fa
121 files changed
+7769
-10469
No files matched your search
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
"on":
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test -- --runInBand
|
||||
- run: pnpm build
|
||||
- run: pnpm audit --prod --audit-level=high
|
||||
- run: pnpm audit --dev --audit-level=high
|
||||
@@ -46,6 +46,7 @@ next-env.d.ts
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
/.worktrees/
|
||||
/.impeccable/
|
||||
|
||||
# generated analysis artifacts
|
||||
/graphify-out/
|
||||
|
||||
@@ -1,88 +1,132 @@
|
||||
<div align="center">
|
||||
|
||||
# Evandro Dev — Portfolio
|
||||
# Evandro.dev.br: portfólio
|
||||
|
||||
**A cinematic, interactive developer portfolio built with Next.js, React, TypeScript, and motion-first UI engineering.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
|
||||
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
## Requisitos
|
||||
|
||||
This is the portfolio site for **Evandro Dev**. It presents professional work, case studies, technical positioning, and interactive visual storytelling through a high-polish Next.js interface.
|
||||
- Node.js `22.x`
|
||||
- pnpm `9.15.9`, fixado em `package.json#packageManager`
|
||||
|
||||
The project combines a modern App Router codebase with motion design, 3D/visual effects, and tested UI components.
|
||||
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.
|
||||
|
||||
## Highlights
|
||||
|
||||
| Area | Details |
|
||||
| --- | --- |
|
||||
| Framework | Next.js App Router with React and TypeScript. |
|
||||
| Motion | GSAP, Framer Motion, Lenis, and custom animation components. |
|
||||
| Visuals | Three.js / React Three Fiber / Drei for interactive and cinematic surfaces. |
|
||||
| Styling | Tailwind CSS, utility composition, and reusable component primitives. |
|
||||
| Quality | Jest, Testing Library, property-based tests, and Biome checks. |
|
||||
| Content | Portfolio sections, case pages, company logos, hero composition, and supporting docs. |
|
||||
|
||||
## Project structure
|
||||
|
||||
```txt
|
||||
app/
|
||||
page.tsx Main portfolio route
|
||||
layout.tsx Root layout and metadata
|
||||
components/ Portfolio sections and cinematic UI modules
|
||||
cases/ Case-study routes/content
|
||||
api/ Server/API routes
|
||||
components/ Shared UI primitives
|
||||
lib/ Utilities and shared logic
|
||||
docs/ Supporting documentation
|
||||
graphify-out/ Project analysis output
|
||||
public/ Static assets
|
||||
```
|
||||
|
||||
## Getting started
|
||||
## Início rápido
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
corepack [email protected] install --frozen-lockfile
|
||||
corepack [email protected] dev
|
||||
```
|
||||
|
||||
The development server uses Turbopack through the project script.
|
||||
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
|
||||
|
||||
```bash
|
||||
corepack [email protected] build
|
||||
corepack [email protected] start
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| Comando | Comportamento |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Start the Next.js development server. |
|
||||
| `pnpm build` | Build the production application. |
|
||||
| `pnpm start` | Start the production server after building. |
|
||||
| `pnpm lint` | Run Biome checks. |
|
||||
| `pnpm format` | Format the codebase with Biome. |
|
||||
| `pnpm test` | Run Jest tests. |
|
||||
| `pnpm test:watch` | Run Jest in watch mode. |
|
||||
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
|
||||
| `pnpm build` | Gera o build de produção com Turbopack. |
|
||||
| `pnpm start` | Serve um build já gerado. |
|
||||
| `pnpm lint` | Executa `biome check`. |
|
||||
| `pnpm typecheck` | Executa TypeScript sem emitir arquivos. |
|
||||
| `pnpm format` | Formata o repositório com Biome. |
|
||||
| `pnpm test` | Executa a suíte Jest. |
|
||||
| `pnpm test:watch` | Executa Jest em modo watch. |
|
||||
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
|
||||
|
||||
## Engineering standards
|
||||
## Stack
|
||||
|
||||
- Keep visual sections modular and testable.
|
||||
- Respect reduced-motion and accessibility expectations for animated sections.
|
||||
- Keep portfolio copy and case-study content easy to update without touching low-level animation code.
|
||||
- Validate interactive components with tests when changing behavior, layout assumptions, or accessibility states.
|
||||
| Área | Pacotes usados |
|
||||
| --- | --- |
|
||||
| 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, Drei e Cobe. |
|
||||
| Interface | Lucide React. |
|
||||
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
|
||||
|
||||
## Validation
|
||||
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
|
||||
|
||||
Before publishing changes:
|
||||
## Rotas
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build
|
||||
| Rota | Conteúdo |
|
||||
| --- | --- |
|
||||
| `/` | 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. |
|
||||
| `/cases/reddit-explorer` | Case Reddit Posts Explorer. |
|
||||
| `/cases/sc-plus` | Case SC Plus Aquicultura. |
|
||||
| `/cases/gcc-installer` | Case MinGW GCC Installer. |
|
||||
| `/cases/as-servicos` | Case AS Serviços. |
|
||||
| `/servicos` | Índice dos seis serviços. |
|
||||
| `/servicos/criacao-de-sites` | Criação de sites. |
|
||||
| `/servicos/engenharia-de-software` | Engenharia de software. |
|
||||
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
|
||||
| `/servicos/tauri-rust` | Desenvolvimento Tauri e Rust. |
|
||||
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
|
||||
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
|
||||
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
|
||||
|
||||
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
|
||||
|
||||
## Comportamento da aplicação
|
||||
|
||||
### Conteúdo e project health
|
||||
|
||||
O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`. A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não remove cards. Ele apenas controla o estado do CTA de cada demo: disponível, indisponível, desconhecido ou sem demo.
|
||||
|
||||
Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md).
|
||||
|
||||
### 3D progressivo e movimento reduzido
|
||||
|
||||
O hero 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No notebook 3D, o loop de renderização só permanece ativo enquanto a seção cruza o viewport e a página está visível.
|
||||
|
||||
### Contato
|
||||
|
||||
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail.
|
||||
|
||||
### Consentimento de analytics
|
||||
|
||||
Google Analytics só é carregado depois de aceite explícito. A decisão fica em `evandro.analytics-consent.v1`; recusar não monta o loader. As preferências permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*` acessíveis.
|
||||
|
||||
## Estrutura
|
||||
|
||||
```text
|
||||
app/ App Router, rotas e componentes da interface
|
||||
lib/ Dados derivados, navegação, JSON-LD e project health
|
||||
public/ Conteúdo JSON, PDF, fontes, imagens e modelo 3D
|
||||
scripts/check-projects.mjs
|
||||
Atualização manual do snapshot de demos
|
||||
data/project-health.json
|
||||
Última observação versionada das demos
|
||||
docs/project-health.md Contrato operacional do snapshot
|
||||
.github/workflows/ci.yml
|
||||
Gate único de integração contínua
|
||||
nixpacks.toml Build e start para Nixpacks
|
||||
```
|
||||
|
||||
## License
|
||||
## CI e deploy
|
||||
|
||||
Personal portfolio project unless a repository license says otherwise.
|
||||
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.
|
||||
|
||||
## Validação local
|
||||
|
||||
```bash
|
||||
corepack [email protected] install --frozen-lockfile
|
||||
corepack [email protected] lint
|
||||
corepack [email protected] typecheck
|
||||
corepack [email protected] test -- --runInBand
|
||||
corepack [email protected] build
|
||||
corepack [email protected] audit --prod --audit-level=high
|
||||
corepack [email protected] audit --dev --audit-level=high
|
||||
```
|
||||
@@ -1,8 +0,0 @@
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
module.exports = ResizeObserver;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
|
||||
function getCaseStudy(slug: string) {
|
||||
return caseStudies.find(({ id }) => id === slug);
|
||||
}
|
||||
|
||||
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
|
||||
return {
|
||||
id: caseStudy.id,
|
||||
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
|
||||
size: OG_SIZE,
|
||||
contentType: OG_CONTENT_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateImageMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug?: string };
|
||||
}) {
|
||||
if (!params.slug) {
|
||||
return caseStudies.map(getImageMetadata);
|
||||
}
|
||||
|
||||
const caseStudy = getCaseStudy(params.slug);
|
||||
if (!caseStudy) notFound();
|
||||
return [getImageMetadata(caseStudy)];
|
||||
}
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
id,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
id: Promise<string | number>;
|
||||
}) {
|
||||
const [{ slug }, imageId] = await Promise.all([params, id]);
|
||||
const caseStudy = getCaseStudy(slug);
|
||||
if (!caseStudy || imageId !== caseStudy.id) notFound();
|
||||
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow="Case de engenharia de software"
|
||||
title={caseStudy.title}
|
||||
description={caseStudy.summary}
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
|
||||
import CasesPage from "../page";
|
||||
import CasePage, { generateMetadata, generateStaticParams } from "./page";
|
||||
|
||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||
|
||||
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
as = "button",
|
||||
strength: _strength,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
as?: string;
|
||||
strength?: number;
|
||||
children?: ReactNode;
|
||||
} & Record<string, unknown>) => {
|
||||
const React = require("react");
|
||||
return React.createElement(as, props, children);
|
||||
},
|
||||
}));
|
||||
|
||||
const publicCases = [
|
||||
{ id: "nutriscan", title: "NutriScan" },
|
||||
{ id: "twins-lab", title: "Twins Lab" },
|
||||
{ id: "reddit-explorer", title: "Reddit Posts Explorer" },
|
||||
{ id: "sc-plus", title: "SC Plus Aquicultura" },
|
||||
{ id: "gcc-installer", title: "MinGW GCC Installer" },
|
||||
{ id: "as-servicos", title: "AS Serviços" },
|
||||
] as const;
|
||||
const publicCaseIds = publicCases.map(({ id }) => id);
|
||||
|
||||
function getCase(id: (typeof publicCases)[number]["id"]) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||
const scripts = container.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]',
|
||||
);
|
||||
if (scripts.length === 0) throw new Error("Missing JSON-LD");
|
||||
|
||||
return Array.from(scripts).flatMap((script) => {
|
||||
if (!script.textContent) throw new Error("Empty JSON-LD");
|
||||
const value = JSON.parse(script.textContent) as Record<string, unknown>;
|
||||
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
|
||||
});
|
||||
}
|
||||
|
||||
function expectCaseCtas() {
|
||||
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 () => {
|
||||
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
||||
expect(staticSlugs).toHaveLength(6);
|
||||
expect(new Set(staticSlugs).size).toBe(6);
|
||||
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
|
||||
|
||||
const caseStudy = getCase("as-servicos");
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
const metadata = await generateMetadata({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
});
|
||||
expect(metadata).toMatchObject({
|
||||
title: expect.stringContaining(caseStudy.title),
|
||||
description: caseStudy.summary,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
},
|
||||
});
|
||||
expect(metadata).not.toHaveProperty("keywords");
|
||||
});
|
||||
|
||||
it("renders the authorized detailed case in order with traceable sources", async () => {
|
||||
const caseStudy = getCase("as-servicos");
|
||||
if (!caseStudy.detail) throw new Error("Missing detailed case content");
|
||||
const ui = await CasePage({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
});
|
||||
const { container } = render(ui);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
const answer = screen.getByText(caseStudy.detail.answer.text);
|
||||
expect(heading.nextElementSibling).toBe(answer);
|
||||
|
||||
const sectionNames = [
|
||||
"Desafio verificável",
|
||||
"Decisões de implementação",
|
||||
"Limites desta evidência",
|
||||
"Fontes",
|
||||
];
|
||||
const sectionHeadings = sectionNames.map((name) =>
|
||||
screen.getByRole("heading", { level: 2, name }),
|
||||
);
|
||||
for (let index = 1; index < sectionHeadings.length; index += 1) {
|
||||
expect(
|
||||
sectionHeadings[index - 1].compareDocumentPosition(
|
||||
sectionHeadings[index],
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
const challengeSection = screen
|
||||
.getByRole("heading", { level: 2, name: "Desafio verificável" })
|
||||
.closest("section");
|
||||
if (!challengeSection) throw new Error("Missing challenge section");
|
||||
const challengeText = within(challengeSection).getByText(
|
||||
caseStudy.detail.challenge.text,
|
||||
);
|
||||
expect(challengeText).toBeVisible();
|
||||
expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]);
|
||||
for (const statement of caseStudy.detail.implementation) {
|
||||
expect(screen.getByText(statement.text)).toBeVisible();
|
||||
}
|
||||
for (const boundary of caseStudy.detail.boundaries) {
|
||||
const boundaryText = screen.getByText(boundary.text);
|
||||
expect(boundaryText).toBeVisible();
|
||||
expect(boundaryText.closest("a")).toBeNull();
|
||||
}
|
||||
|
||||
const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ });
|
||||
expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length);
|
||||
for (const source of caseStudy.detail.sources) {
|
||||
const link = screen.getByRole("link", { name: `Fonte: ${source.label}` });
|
||||
expect(link).toHaveAttribute("href", source.url);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/));
|
||||
expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//);
|
||||
const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1];
|
||||
if (!sha) throw new Error(`Missing source SHA for ${source.id}`);
|
||||
expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible();
|
||||
}
|
||||
|
||||
expectCaseCtas();
|
||||
|
||||
const graph = readJsonLdEntities(container);
|
||||
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
|
||||
expect(JSON.stringify(graph)).not.toMatch(
|
||||
/Article|FAQPage|ProfessionalService|LocalBusiness/,
|
||||
);
|
||||
const breadcrumb = graph[0] as {
|
||||
itemListElement: Array<{
|
||||
"@type": string;
|
||||
position: number;
|
||||
name: string;
|
||||
item: string;
|
||||
}>;
|
||||
};
|
||||
const canonicalUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
expect(breadcrumb.itemListElement).toEqual([
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Cases",
|
||||
item: `${siteUrl}/cases`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: caseStudy.title,
|
||||
item: canonicalUrl,
|
||||
},
|
||||
]);
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
.getAllByRole("link")
|
||||
.map((link) => ({
|
||||
name: link.textContent,
|
||||
href: link.getAttribute("href"),
|
||||
current: link.getAttribute("aria-current"),
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "Início", href: "/", current: null },
|
||||
{ name: "Cases", href: "/cases", current: null },
|
||||
{
|
||||
name: caseStudy.title,
|
||||
href: `/cases/${caseStudy.id}`,
|
||||
current: "page",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the fallback concise and links its declared services", async () => {
|
||||
const caseStudy = getCase("gcc-installer");
|
||||
expect(caseStudy.detail).toBeUndefined();
|
||||
const ui = await CasePage({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
});
|
||||
const { container } = render(ui);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
|
||||
expect(screen.getByText(caseStudy.impact)).toBeVisible();
|
||||
for (const technology of caseStudy.stack) {
|
||||
expect(screen.getByText(technology)).toBeVisible();
|
||||
}
|
||||
expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
|
||||
"href",
|
||||
caseStudy.githubUrl,
|
||||
);
|
||||
for (const name of [
|
||||
"Desafio verificável",
|
||||
"Decisões de implementação",
|
||||
"Limites desta evidência",
|
||||
"Fontes",
|
||||
]) {
|
||||
expect(screen.queryByRole("heading", { name })).toBeNull();
|
||||
}
|
||||
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
|
||||
|
||||
const expectedServices = servicePages
|
||||
.filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id))
|
||||
.map(({ slug, navLabel }) => ({
|
||||
name: `Ver serviço relacionado: ${navLabel}`,
|
||||
href: `/servicos/${slug}`,
|
||||
}))
|
||||
.sort((a, b) => a.href.localeCompare(b.href));
|
||||
const relatedLinks = screen.getAllByRole("link", {
|
||||
name: /^Ver serviço relacionado:/,
|
||||
});
|
||||
expect(
|
||||
relatedLinks
|
||||
.map((link) => ({
|
||||
name: link.textContent,
|
||||
href: link.getAttribute("href"),
|
||||
}))
|
||||
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
|
||||
).toEqual(expectedServices);
|
||||
expectCaseCtas();
|
||||
|
||||
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
|
||||
"BreadcrumbList",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the hub cards, schema and distinct intents aligned", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i);
|
||||
|
||||
const caseLinks = screen.getAllByRole("link", { name: "Ler case" });
|
||||
const caseHrefs = caseLinks.map((link) => link.getAttribute("href"));
|
||||
expect(caseHrefs).toHaveLength(6);
|
||||
expect(new Set(caseHrefs).size).toBe(6);
|
||||
expect([...caseHrefs].sort()).toEqual(
|
||||
publicCaseIds.map((id) => `/cases/${id}`).sort(),
|
||||
);
|
||||
expectCaseCtas();
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
.getAllByRole("link")
|
||||
.map((link) => [link.textContent, link.getAttribute("href")]),
|
||||
).toEqual([
|
||||
["Início", "/"],
|
||||
["Cases", "/cases"],
|
||||
]);
|
||||
|
||||
const graph = readJsonLdEntities(container);
|
||||
expect(graph.map((node) => node["@type"])).toEqual([
|
||||
"CollectionPage",
|
||||
"ItemList",
|
||||
"BreadcrumbList",
|
||||
]);
|
||||
const itemList = graph[1] as {
|
||||
itemListElement: Array<{
|
||||
"@type": string;
|
||||
position: number;
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
const visibleItems = caseLinks.map((link, index) => {
|
||||
const href = link.getAttribute("href");
|
||||
const expected = publicCases.find(({ id }) => href === `/cases/${id}`);
|
||||
if (!expected) throw new Error(`Unexpected visible case link ${href}`);
|
||||
const card = link.closest("article");
|
||||
if (!card) throw new Error(`Missing visible card for ${href}`);
|
||||
expect(within(card).getByText(expected.title)).toBeVisible();
|
||||
return {
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: expected.title,
|
||||
url: `${siteUrl}${href}`,
|
||||
};
|
||||
});
|
||||
expect(itemList.itemListElement).toEqual(visibleItems);
|
||||
});
|
||||
+161
-57
@@ -2,12 +2,25 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
||||
import { buildBreadcrumbList } from "@/lib/seo";
|
||||
import {
|
||||
caseStudies,
|
||||
getRelatedServices,
|
||||
intentCtas,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
|
||||
interface CasePageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//;
|
||||
|
||||
function getSourceSha(url: string) {
|
||||
return sourceShaPattern.exec(url)?.[1];
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
|
||||
}
|
||||
@@ -45,36 +58,16 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === slug);
|
||||
if (!caseStudy) notFound();
|
||||
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
const relatedServices = getRelatedServices(caseStudy.id);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Article",
|
||||
headline: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
mainEntityOfPage: url,
|
||||
author: { "@id": `${siteUrl}/#person` },
|
||||
about: caseStudy.stack,
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Cases",
|
||||
item: `${siteUrl}/cases`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: caseStudy.title,
|
||||
item: url,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Cases", `${siteUrl}/cases`],
|
||||
[caseStudy.title, pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -84,18 +77,52 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
<article>
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<li>
|
||||
<Link className="ds-link ds-link-muted" href="/">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link className="ds-link ds-link-muted" href="/cases">
|
||||
Cases
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link
|
||||
aria-current="page"
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
>
|
||||
{caseStudy.title}
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">Case de engenharia de software</p>
|
||||
<h1 className="ds-section-title text-white">
|
||||
{caseStudy.title}: {caseStudy.subtitle}
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
|
||||
{caseStudy.summary}
|
||||
</p>
|
||||
<p className="mt-sp-6 text-[15px] leading-8 text-white">
|
||||
{caseStudy.impact}
|
||||
{caseStudy.detail?.answer.text ?? caseStudy.summary}
|
||||
</p>
|
||||
{!caseStudy.detail ? (
|
||||
<p className="mt-sp-6 text-[15px] leading-8 text-white">
|
||||
{caseStudy.impact}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{caseStudy.stack.map((item) => (
|
||||
<li key={item} className="ds-tag">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
{caseStudy.demoUrl && (
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
className="ds-link ds-link-accent"
|
||||
href={caseStudy.demoUrl}
|
||||
@@ -104,8 +131,8 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
>
|
||||
Abrir demonstração
|
||||
</a>
|
||||
)}
|
||||
{caseStudy.githubUrl && (
|
||||
) : null}
|
||||
{caseStudy.githubUrl ? (
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={caseStudy.githubUrl}
|
||||
@@ -114,33 +141,110 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
>
|
||||
Ver código
|
||||
</a>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="ds-section">
|
||||
|
||||
{caseStudy.detail ? (
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell max-w-[960px] space-y-sp-10">
|
||||
<section aria-labelledby="case-challenge">
|
||||
<h2 id="case-challenge" className="ds-section-title">
|
||||
Desafio verificável
|
||||
</h2>
|
||||
<p className="ds-section-description">
|
||||
{caseStudy.detail.challenge.text}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="case-implementation">
|
||||
<h2 id="case-implementation" className="ds-section-title">
|
||||
Decisões de implementação
|
||||
</h2>
|
||||
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
|
||||
{caseStudy.detail.implementation.map((statement) => (
|
||||
<li key={statement.text}>{statement.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="case-boundaries">
|
||||
<h2 id="case-boundaries" className="ds-section-title">
|
||||
Limites desta evidência
|
||||
</h2>
|
||||
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
|
||||
{caseStudy.detail.boundaries.map((boundary) => (
|
||||
<li key={boundary.text}>{boundary.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="case-sources">
|
||||
<h2 id="case-sources" className="ds-section-title">
|
||||
Fontes
|
||||
</h2>
|
||||
<ul className="mt-sp-5 space-y-sp-4">
|
||||
{caseStudy.detail.sources.map((source) => {
|
||||
const sha = getSourceSha(source.url);
|
||||
return (
|
||||
<li key={source.id}>
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Fonte: {source.label}
|
||||
</a>
|
||||
{sha ? (
|
||||
<span className="mt-sp-2 block text-[12px] text-[var(--meta)]">
|
||||
Commit: <code className="break-all">{sha}</code>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{relatedServices.length > 0 ? (
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 className="ds-section-title">Serviços relacionados</h2>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
{relatedServices.map((service) => (
|
||||
<Link
|
||||
key={service.slug}
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/servicos/${service.slug}`}
|
||||
>
|
||||
Ver serviço relacionado: {service.navLabel}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="ds-section ds-section-dark pb-sp-6">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 className="ds-section-title !leading-snug">
|
||||
Tecnologias e contexto técnico
|
||||
</h2>
|
||||
<p className="ds-section-description">
|
||||
Este case demonstra como aplico engenharia de software a produtos
|
||||
com requisitos de performance, integração e operação, para
|
||||
empresas do Norte de Santa Catarina, Curitiba e atuação remota.
|
||||
</p>
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{caseStudy.stack.map((item) => (
|
||||
<li key={item} className="ds-tag">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
||||
<Link className="ds-link ds-link-accent" href="/curriculo">
|
||||
Ver currículo profissional
|
||||
<h2 className="ds-card-title text-white">Próximos caminhos</h2>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<Link
|
||||
className="ds-link ds-link-accent"
|
||||
href={intentCtas.hiring.href}
|
||||
>
|
||||
{intentCtas.hiring.label}
|
||||
</Link>
|
||||
<Link className="ds-link ds-link-muted" href="/servicos">
|
||||
Ver serviços de engenharia
|
||||
<Link
|
||||
className="ds-link ds-link-muted"
|
||||
href={intentCtas.consulting.href}
|
||||
>
|
||||
{intentCtas.consulting.label}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+46
-34
@@ -3,20 +3,24 @@ import Link from "next/link";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
||||
import { buildBreadcrumbList } from "@/lib/seo";
|
||||
import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/cases`;
|
||||
const pageTitle = "Cases de Engenharia de Software, Next.js e IoT";
|
||||
const pageDescription =
|
||||
"Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cases de Engenharia de Software, Next.js e IoT",
|
||||
description:
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos. Portfolio técnico com foco em clareza, performance e execução.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/cases`,
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title: "Cases de Engenharia de Software, Next.js e IoT",
|
||||
description:
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos.",
|
||||
url: `${siteUrl}/cases`,
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
siteName,
|
||||
type: "website",
|
||||
},
|
||||
@@ -28,11 +32,10 @@ export default function CasesPage() {
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "CollectionPage",
|
||||
"@id": `${siteUrl}/cases#page`,
|
||||
name: "Cases de engenharia de software",
|
||||
url: `${siteUrl}/cases`,
|
||||
description:
|
||||
"Coleção de cases em Next.js, IoT, integrações e software para operações críticas.",
|
||||
"@id": `${pageUrl}#page`,
|
||||
name: pageTitle,
|
||||
url: pageUrl,
|
||||
description: pageDescription,
|
||||
},
|
||||
{
|
||||
"@type": "ItemList",
|
||||
@@ -40,21 +43,13 @@ export default function CasesPage() {
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: caseStudy.title,
|
||||
url: `${siteUrl}/cases/${caseStudy.id}`,
|
||||
url: `${pageUrl}/${caseStudy.id}`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Cases",
|
||||
item: `${siteUrl}/cases`,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Cases", pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -66,14 +61,31 @@ export default function CasesPage() {
|
||||
<HeroBackground />
|
||||
<div className="ds-section-shell relative z-10">
|
||||
<div className="max-w-[860px]">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<li>
|
||||
<Link className="ds-link ds-link-muted" href="/">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link
|
||||
aria-current="page"
|
||||
className="ds-link ds-link-muted"
|
||||
href="/cases"
|
||||
>
|
||||
Cases
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">Selected work</p>
|
||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
||||
Cases em software, Next.js, integrações e produtos conectados.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
Esta página organiza projetos com leitura mais indexável e
|
||||
comercial, conectando stack, contexto técnico e tipo de problema
|
||||
resolvido.
|
||||
{pageDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,19 +136,19 @@ export default function CasesPage() {
|
||||
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/servicos"
|
||||
href={intentCtas.hiring.href}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver serviços
|
||||
{intentCtas.hiring.label}
|
||||
</MagneticButton>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/#contact"
|
||||
href={intentCtas.consulting.href}
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.2}
|
||||
>
|
||||
Ir para contato
|
||||
{intentCtas.consulting.label}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock motion/react with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const actual = jest.requireActual("motion/react");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
motion: {
|
||||
div: ({ children, animate, transition, ...props }: any) => {
|
||||
return <div {...props}>{children}</div>;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Property 10: Alt Text for Images", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have non-empty alt attributes for all company logo images", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image in the CompanyLogos component,
|
||||
* it should have a non-empty alt attribute describing the company.
|
||||
* This ensures accessibility for screen readers and assistive technologies.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify we found images
|
||||
if (logoImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all images have non-empty alt text
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have descriptive alt text that includes company name", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image, the alt text should be descriptive
|
||||
* and include identifying information about the company. This ensures
|
||||
* meaningful accessibility information is provided.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify we found images
|
||||
if (logoImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all alt text contains "logo" or is descriptive
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
if (!alt) return false;
|
||||
|
||||
const altLower = alt.toLowerCase();
|
||||
// Alt text should contain "logo" or be at least 5 characters (descriptive)
|
||||
return altLower.includes("logo") || alt.trim().length >= 5;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain alt text consistency across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any number of component renders, the alt text for each
|
||||
* company logo should remain consistent. This ensures stable accessibility
|
||||
* information across re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 2, max: 5 }), // Test 2-5 renders
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(numRenders, shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
let firstRenderAltTexts: string[] = [];
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
const currentAltTexts = Array.from(logoImages).map(
|
||||
(img) => img.getAttribute("alt") || "",
|
||||
);
|
||||
|
||||
if (i === 0) {
|
||||
firstRenderAltTexts = currentAltTexts;
|
||||
} else {
|
||||
// Compare with first render
|
||||
if (currentAltTexts.length !== firstRenderAltTexts.length) {
|
||||
unmount();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that alt texts match (accounting for duplicates in animated mode)
|
||||
const expectedCount = shouldReduce ? 5 : 10;
|
||||
if (currentAltTexts.length !== expectedCount) {
|
||||
unmount();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have unique alt text for each distinct company", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any set of company logos, each distinct company should have
|
||||
* its own unique alt text. This ensures each company is properly identified
|
||||
* for accessibility purposes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true; // Use static mode to get unique logos
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
const altTexts = Array.from(logoImages).map(
|
||||
(img) => img.getAttribute("alt") || "",
|
||||
);
|
||||
|
||||
// In static mode, we should have 5 unique alt texts
|
||||
const uniqueAltTexts = new Set(altTexts);
|
||||
|
||||
// Should have 5 unique alt texts (one per company)
|
||||
return uniqueAltTexts.size === 5 && altTexts.length === 5;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve alt text in both animated and static modes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any reduced motion preference, all company logo images
|
||||
* should maintain their alt text attributes. The animation mode should
|
||||
* not affect accessibility attributes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify all images have alt attributes
|
||||
const allHaveAlt = Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
|
||||
// Verify correct number of images based on mode
|
||||
const expectedCount = shouldReduce ? 5 : 10;
|
||||
const hasCorrectCount = logoImages.length === expectedCount;
|
||||
|
||||
return allHaveAlt && hasCorrectCount;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have empty or whitespace-only alt text", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image, the alt text should not be empty
|
||||
* or consist only of whitespace. This ensures meaningful accessibility
|
||||
* information is always provided.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Check that no image has empty or whitespace-only alt text
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
|
||||
// Alt must exist and not be empty after trimming
|
||||
if (!alt || alt.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Alt should not be just whitespace
|
||||
return alt !== alt.trim() ? false : true;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have alt text that matches the expected company names", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image, the alt text should correspond to
|
||||
* one of the expected company names (Apolo Energia, Julia Beauty Lashes,
|
||||
* Innovo, TeraByteShop, Pichau). This ensures correct identification.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true; // Use static mode for clarity
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
const altTexts = Array.from(logoImages).map(
|
||||
(img) => img.getAttribute("alt") || "",
|
||||
);
|
||||
|
||||
// Expected company names in alt text
|
||||
const expectedCompanies = [
|
||||
"Apolo Energia",
|
||||
"Julia Beauty Lashes",
|
||||
"Innovo",
|
||||
"TeraByteShop",
|
||||
"Pichau",
|
||||
];
|
||||
|
||||
// Check that each alt text contains one of the expected company names
|
||||
return altTexts.every((alt) =>
|
||||
expectedCompanies.some((company) => alt.includes(company)),
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain alt text when images fail to load", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image that fails to load, the alt text
|
||||
* should still be present in the DOM. This ensures accessibility is
|
||||
* maintained even when images are unavailable.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify all images have alt text before any error handling
|
||||
const allHaveAltBeforeError = Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
|
||||
// The alt attribute should be present regardless of load state
|
||||
return allHaveAltBeforeError;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,344 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock motion/react with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const actual = jest.requireActual("motion/react");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
motion: {
|
||||
div: ({ children, animate, transition, ...props }: any) => {
|
||||
// Capture animation props for testing
|
||||
const animationProps = {
|
||||
"data-animate": JSON.stringify(animate),
|
||||
"data-transition": JSON.stringify(transition),
|
||||
};
|
||||
return (
|
||||
<div {...props} {...animationProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Property 4: Reduced Motion Respect", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should render static grid layout when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any reduced motion preference, when useReducedMotion() returns true,
|
||||
* the CompanyLogos component should render a static grid layout without motion
|
||||
* animations. This ensures accessibility compliance with prefers-reduced-motion.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for animated motion.div with animation props
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
|
||||
// Check for static grid layout
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
|
||||
if (shouldReduce) {
|
||||
// When reduced motion is enabled:
|
||||
// - Should NOT have animated elements
|
||||
// - Should have static grid layout
|
||||
return animatedElements.length === 0 && staticGrid !== null;
|
||||
} else {
|
||||
// When reduced motion is disabled:
|
||||
// - Should have animated elements
|
||||
// - Should NOT have static grid layout
|
||||
return animatedElements.length > 0 && staticGrid === null;
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should exclude horizontal scroll animation when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any component render with reduced motion enabled, the horizontal
|
||||
* scroll animation (x: [0, '-50%']) should be completely absent. This verifies
|
||||
* that motion animations are properly disabled.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed
|
||||
() => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for animation props
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
|
||||
let hasScrollAnimation = false;
|
||||
|
||||
animatedElements.forEach((element) => {
|
||||
const animateValue = element.getAttribute("data-animate");
|
||||
if (animateValue && animateValue !== "undefined") {
|
||||
try {
|
||||
const props = JSON.parse(animateValue);
|
||||
|
||||
// Check for x animation (horizontal scroll)
|
||||
if (props.x !== undefined) {
|
||||
hasScrollAnimation = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Should not have scroll animation when reduced motion is enabled
|
||||
return !hasScrollAnimation;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should display all company logos in both animated and static modes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any reduced motion preference, all company logos should be
|
||||
* displayed. The reduced motion preference should only affect the animation
|
||||
* style, not the content visibility.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll('img[alt*="logo"]');
|
||||
|
||||
// Should have at least 5 logos (the original set)
|
||||
// In animated mode, we have duplicates, so it will be 10
|
||||
// In static mode, we have exactly 5
|
||||
if (shouldReduce) {
|
||||
return logoImages.length === 5;
|
||||
} else {
|
||||
return logoImages.length === 10; // Duplicated for infinite scroll
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain accessibility attributes regardless of reduced motion preference", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3, 6.2**
|
||||
*
|
||||
* Property: For any reduced motion preference, all company logo images should
|
||||
* maintain proper alt text attributes. Accessibility should not be compromised
|
||||
* by the animation mode.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify we found images
|
||||
if (logoImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all images have non-empty alt text
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should consistently apply reduced motion across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any number of component renders with reduced motion enabled,
|
||||
* the static layout should be consistently rendered without animations.
|
||||
* This verifies that the reduced motion behavior is stable across re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
|
||||
(numRenders) => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
let allRendersValid = true;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<CompanyLogos />);
|
||||
|
||||
// Check for animated elements
|
||||
const animatedElements =
|
||||
container.querySelectorAll("[data-animate]");
|
||||
|
||||
// Check for static grid
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
|
||||
// Should have no animations and should have static grid
|
||||
if (animatedElements.length > 0 || staticGrid === null) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use grid layout classes when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: When reduced motion is enabled, the component should use CSS grid
|
||||
* layout classes (grid, grid-cols-*) to display logos in a static arrangement.
|
||||
* This ensures proper visual presentation without animation.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for grid layout classes
|
||||
const gridContainer = container.querySelector(".grid");
|
||||
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify grid has responsive column classes
|
||||
const hasGridCols = gridContainer.className.includes("grid-cols-");
|
||||
|
||||
return hasGridCols;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have infinite repeat transition when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: When reduced motion is enabled, the component should not have
|
||||
* transition configurations with infinite repeat. This ensures no continuous
|
||||
* motion occurs when users prefer reduced motion.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for transition props
|
||||
const elementsWithTransition =
|
||||
container.querySelectorAll("[data-transition]");
|
||||
|
||||
let hasInfiniteRepeat = false;
|
||||
|
||||
elementsWithTransition.forEach((element) => {
|
||||
const transitionValue = element.getAttribute("data-transition");
|
||||
if (transitionValue && transitionValue !== "undefined") {
|
||||
try {
|
||||
const props = JSON.parse(transitionValue);
|
||||
|
||||
// Check for infinite repeat
|
||||
if (props.repeat === Infinity || props.repeat === "Infinity") {
|
||||
hasInfiniteRepeat = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Should not have infinite repeat when reduced motion is enabled
|
||||
return !hasInfiniteRepeat;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should switch between animated and static modes based on reduced motion preference", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: The component should render different layouts based on the reduced
|
||||
* motion preference. When the preference changes, the component should adapt
|
||||
* accordingly. This verifies the conditional rendering logic.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), // Sequence of preference changes
|
||||
(preferenceSequence) => {
|
||||
let allTransitionsValid = true;
|
||||
|
||||
preferenceSequence.forEach((shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container, unmount } = render(<CompanyLogos />);
|
||||
|
||||
const hasAnimations =
|
||||
container.querySelectorAll("[data-animate]").length > 0;
|
||||
const hasStaticGrid = container.querySelector(".grid") !== null;
|
||||
|
||||
// Verify correct mode is rendered
|
||||
if (shouldReduce) {
|
||||
if (hasAnimations || !hasStaticGrid) {
|
||||
allTransitionsValid = false;
|
||||
}
|
||||
} else {
|
||||
if (!hasAnimations || hasStaticGrid) {
|
||||
allTransitionsValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
return allTransitionsValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,503 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock motion/react with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const actual = jest.requireActual("motion/react");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
motion: {
|
||||
div: ({ children, animate, transition, ...props }: any) => {
|
||||
// Capture animation props for testing
|
||||
const animationProps = {
|
||||
"data-animate": JSON.stringify(animate),
|
||||
"data-transition": JSON.stringify(transition),
|
||||
};
|
||||
return (
|
||||
<div {...props} {...animationProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Logo Rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should render all five company logos", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// In animated mode, logos are duplicated (10 total)
|
||||
const logoImages = container.querySelectorAll('img[alt*="logo"]');
|
||||
expect(logoImages.length).toBe(10); // 5 logos x 2 (duplicated for infinite scroll)
|
||||
});
|
||||
|
||||
it("should render Apolo Energia logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
|
||||
expect(apoloLogos.length).toBe(2); // Duplicated for infinite scroll
|
||||
expect(apoloLogos[0]).toHaveAttribute("src", "/logos/apolo.svg");
|
||||
});
|
||||
|
||||
it("should render Julia Beauty Lashes logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
|
||||
expect(juliaLogos.length).toBe(2);
|
||||
expect(juliaLogos[0]).toHaveAttribute("src", "/logos/julia.svg");
|
||||
});
|
||||
|
||||
it("should render Innovo logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const innovoLogos = screen.getAllByAltText("Innovo logo");
|
||||
expect(innovoLogos.length).toBe(2);
|
||||
expect(innovoLogos[0]).toHaveAttribute("src", "/logos/innovo.svg");
|
||||
});
|
||||
|
||||
it("should render TeraByteShop logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
|
||||
expect(terabyteLogos.length).toBe(2);
|
||||
expect(terabyteLogos[0]).toHaveAttribute("src", "/logos/terabyte.svg");
|
||||
});
|
||||
|
||||
it("should render Pichau logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const pichauLogos = screen.getAllByAltText("Pichau logo");
|
||||
expect(pichauLogos.length).toBe(2);
|
||||
expect(pichauLogos[0]).toHaveAttribute("src", "/logos/pichau.svg");
|
||||
});
|
||||
|
||||
it("should have alt text for all logo images", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
expect(alt).toBeTruthy();
|
||||
expect(alt?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should duplicate logo array for seamless infinite scroll", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Each company should appear exactly twice
|
||||
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
|
||||
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
|
||||
const innovoLogos = screen.getAllByAltText("Innovo logo");
|
||||
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
|
||||
const pichauLogos = screen.getAllByAltText("Pichau logo");
|
||||
|
||||
expect(apoloLogos.length).toBe(2);
|
||||
expect(juliaLogos.length).toBe(2);
|
||||
expect(innovoLogos.length).toBe(2);
|
||||
expect(terabyteLogos.length).toBe(2);
|
||||
expect(pichauLogos.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Animation Configuration", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have horizontal scroll animation configuration", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-animate]");
|
||||
expect(animatedElement).toBeInTheDocument();
|
||||
|
||||
const animateValue = animatedElement?.getAttribute("data-animate");
|
||||
expect(animateValue).toBeTruthy();
|
||||
|
||||
const animateProps = JSON.parse(animateValue!);
|
||||
expect(animateProps.x).toEqual([0, "-50%"]);
|
||||
});
|
||||
|
||||
it("should have 30-second animation duration", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-transition]");
|
||||
expect(animatedElement).toBeInTheDocument();
|
||||
|
||||
const transitionValue = animatedElement?.getAttribute("data-transition");
|
||||
expect(transitionValue).toBeTruthy();
|
||||
|
||||
const transitionProps = JSON.parse(transitionValue!);
|
||||
expect(transitionProps.duration).toBe(30);
|
||||
});
|
||||
|
||||
it("should have infinite repeat configuration", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-transition]");
|
||||
const transitionValue = animatedElement?.getAttribute("data-transition");
|
||||
const transitionProps = JSON.parse(transitionValue!);
|
||||
|
||||
// JSON.stringify converts Infinity to null, so we check for null
|
||||
expect(transitionProps.repeat).toBe(null);
|
||||
});
|
||||
|
||||
it("should use linear easing for smooth animation", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-transition]");
|
||||
const transitionValue = animatedElement?.getAttribute("data-transition");
|
||||
const transitionProps = JSON.parse(transitionValue!);
|
||||
|
||||
expect(transitionProps.ease).toBe("linear");
|
||||
});
|
||||
|
||||
it("should have flex layout with gap-sp-12 for logo spacing", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-animate]");
|
||||
const classes = animatedElement?.className || "";
|
||||
|
||||
expect(classes).toContain("flex");
|
||||
expect(classes).toContain("gap-sp-12");
|
||||
});
|
||||
|
||||
it("should have items-center alignment", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-animate]");
|
||||
const classes = animatedElement?.className || "";
|
||||
|
||||
expect(classes).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Reduced Motion Fallback", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should render static grid when reduced motion is enabled", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
expect(staticGrid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not have animated elements when reduced motion is enabled", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
expect(animatedElements.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should render exactly 5 logos in static mode (no duplication)", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll('img[alt*="logo"]');
|
||||
expect(logoImages.length).toBe(5);
|
||||
});
|
||||
|
||||
it("should have responsive grid columns in static mode", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
const classes = staticGrid?.className || "";
|
||||
|
||||
expect(classes).toContain("grid-cols-2");
|
||||
expect(classes).toContain("sm:grid-cols-3");
|
||||
expect(classes).toContain("md:grid-cols-5");
|
||||
});
|
||||
|
||||
it("should have gap-sp-6 spacing in static grid", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
const classes = staticGrid?.className || "";
|
||||
|
||||
expect(classes).toContain("gap-sp-6");
|
||||
});
|
||||
|
||||
it("should maintain all logo alt text in static mode", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
expect(alt).toBeTruthy();
|
||||
expect(alt?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Missing Logo Image Handling", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have onError handler for logo images", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
// Check that the image element has an onError handler
|
||||
// In React, this is attached as a prop, so we verify the element exists
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply grayscale filter to logo images", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("grayscale");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have hover effect to remove grayscale", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("hover:grayscale-0");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have opacity transition on hover", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("opacity-60");
|
||||
expect(classes).toContain("hover:opacity-100");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have transition-all for smooth hover effects", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("transition-all");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Responsive Layout", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have section element as container", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have full width class", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("w-full");
|
||||
});
|
||||
|
||||
it("should have overflow-hidden for animation containment", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
it("should have border-b for visual separation", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("border-b");
|
||||
});
|
||||
|
||||
it("should have background color class", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("bg-[var(--paper)]");
|
||||
});
|
||||
|
||||
it("should have py-sp-8 vertical padding", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// In animated mode, padding is on inner div
|
||||
const innerDiv = container.querySelector(".py-sp-8");
|
||||
expect(innerDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have responsive padding in static mode", () => {
|
||||
mockReducedMotion = true;
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const innerContainer = container.querySelector(".container");
|
||||
const classes = innerContainer?.className || "";
|
||||
|
||||
expect(classes).toContain("px-sp-6");
|
||||
expect(classes).toContain("py-sp-8");
|
||||
expect(classes).toContain("md:px-sp-10");
|
||||
expect(classes).toContain("lg:px-sp-16");
|
||||
});
|
||||
|
||||
it("should have mx-auto for centering in static mode", () => {
|
||||
mockReducedMotion = true;
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const innerContainer = container.querySelector(".container");
|
||||
const classes = innerContainer?.className || "";
|
||||
|
||||
expect(classes).toContain("mx-auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Logo Sizing and Styling", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have h-12 height for all logos", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("h-12");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have w-auto width for aspect ratio preservation", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("w-auto");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have object-contain for proper image scaling", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("object-contain");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have flex-shrink-0 on logo containers in animated mode", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoContainers = container.querySelectorAll("[data-animate] > div");
|
||||
|
||||
logoContainers.forEach((div) => {
|
||||
const classes = div.className;
|
||||
expect(classes).toContain("flex-shrink-0");
|
||||
});
|
||||
});
|
||||
|
||||
it("should center logos within their containers", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoContainers = container.querySelectorAll("[data-animate] > div");
|
||||
|
||||
logoContainers.forEach((div) => {
|
||||
const classes = div.className;
|
||||
expect(classes).toContain("flex");
|
||||
expect(classes).toContain("items-center");
|
||||
expect(classes).toContain("justify-center");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Accessibility", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should use semantic section element", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have descriptive alt text for each logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
expect(screen.getAllByAltText("Apolo Energia logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("Julia Beauty Lashes logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("Innovo logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("TeraByteShop logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("Pichau logo")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should respect reduced motion preference", () => {
|
||||
mockReducedMotion = true;
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Should render static grid instead of animation
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
|
||||
expect(staticGrid).toBeInTheDocument();
|
||||
expect(animatedElements.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should maintain content visibility in both modes", () => {
|
||||
// Test animated mode
|
||||
mockReducedMotion = false;
|
||||
const { container: animatedContainer } = render(<CompanyLogos />);
|
||||
const animatedLogos =
|
||||
animatedContainer.querySelectorAll('img[alt*="logo"]');
|
||||
|
||||
// Test static mode
|
||||
mockReducedMotion = true;
|
||||
const { container: staticContainer } = render(<CompanyLogos />);
|
||||
const staticLogos = staticContainer.querySelectorAll('img[alt*="logo"]');
|
||||
|
||||
// Both modes should display logos (different counts due to duplication)
|
||||
expect(animatedLogos.length).toBeGreaterThan(0);
|
||||
expect(staticLogos.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CompanyLogos Component
|
||||
*
|
||||
* Displays an infinite-scrolling banner of company logos after the header.
|
||||
* Features smooth horizontal animation with reduced motion support.
|
||||
*
|
||||
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
|
||||
*/
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
// TypeScript interface for company logos
|
||||
export interface CompanyLogo {
|
||||
name: string; // Company name
|
||||
imagePath: string; // Path to logo image (SVG preferred)
|
||||
alt: string; // Alt text for accessibility
|
||||
}
|
||||
|
||||
// Company logos data
|
||||
const companies: CompanyLogo[] = [
|
||||
{
|
||||
name: "Apolo Energia",
|
||||
imagePath: "/logos/apolo.svg",
|
||||
alt: "Apolo Energia logo",
|
||||
},
|
||||
{
|
||||
name: "Julia Beauty Lashes",
|
||||
imagePath: "/logos/julia.svg",
|
||||
alt: "Julia Beauty Lashes logo",
|
||||
},
|
||||
{
|
||||
name: "Innovo",
|
||||
imagePath: "/logos/innovo.svg",
|
||||
alt: "Innovo logo",
|
||||
},
|
||||
{
|
||||
name: "TeraByteShop",
|
||||
imagePath: "/logos/terabyte.svg",
|
||||
alt: "TeraByteShop logo",
|
||||
},
|
||||
{
|
||||
name: "Pichau",
|
||||
imagePath: "/logos/pichau.svg",
|
||||
alt: "Pichau logo",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CompanyLogos() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
// Duplicate logo array for seamless infinite scroll
|
||||
const duplicatedLogos = [...companies, ...companies];
|
||||
|
||||
// Static grid for reduced motion preference
|
||||
if (shouldReduceMotion) {
|
||||
return (
|
||||
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
|
||||
<div className="container mx-auto px-sp-6 py-sp-8 md:px-sp-10 lg:px-sp-16">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-sp-6 items-center justify-items-center">
|
||||
{companies.map((logo) => (
|
||||
<div key={logo.name} className="flex items-center justify-center">
|
||||
<Image
|
||||
src={logo.imagePath}
|
||||
alt={logo.alt}
|
||||
width={150}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain grayscale opacity-60"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Animated infinite scroll
|
||||
return (
|
||||
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
|
||||
<div className="py-sp-8" data-reel="zoom" data-reel-distance="40">
|
||||
<motion.div
|
||||
className="flex gap-sp-12 items-center"
|
||||
animate={{
|
||||
x: [0, "-50%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 30,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{duplicatedLogos.map((logo, index) => (
|
||||
<div
|
||||
key={`${logo.name}-${index}`}
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
// Second half is a visual duplicate for seamless looping; hide from AT
|
||||
aria-hidden={index >= companies.length ? true : undefined}
|
||||
>
|
||||
<Image
|
||||
src={logo.imagePath}
|
||||
alt={logo.alt}
|
||||
width={150}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,911 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
|
||||
interface ContactFormProps {
|
||||
formType: "simple" | "advanced";
|
||||
setFormType: (type: "simple" | "advanced") => void;
|
||||
}
|
||||
|
||||
export function ContactForm({ formType, setFormType }: ContactFormProps) {
|
||||
const { data } = usePortfolioData();
|
||||
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
|
||||
"whatsapp",
|
||||
);
|
||||
|
||||
// MENTORIA: Agrupamento de estado para reduzir complexidade e re-renders
|
||||
const [formData, setFormData] = useState({
|
||||
userName: "",
|
||||
userEmail: "",
|
||||
userPhone: "",
|
||||
userCompany: "",
|
||||
howFound: "",
|
||||
projectType: "",
|
||||
otherProjectType: "",
|
||||
desiredDeadline: "",
|
||||
budgetValue: "",
|
||||
technicalComplexity: "",
|
||||
hasInternalTeam: "",
|
||||
customMessage: "",
|
||||
});
|
||||
|
||||
const [isDecisor, setIsDecisor] = useState<string>("");
|
||||
const [projectApproved, setProjectApproved] = useState<string>("");
|
||||
const [urgency, setUrgency] = useState<string>("");
|
||||
const [preferredModality, setPreferredModality] = useState<string>("");
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(false); // Novo estado para controlar a expansão do formulário avançado
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [phoneError, setPhoneError] = useState<string | null>(null);
|
||||
const [otherProjectTypeError, setOtherProjectTypeError] = useState<
|
||||
string | null
|
||||
>(null); // Novo estado de erro
|
||||
const [honeypot, setHoneypot] = useState<string>(""); // Estado para o campo honeypot
|
||||
const [startTime, setStartTime] = useState<number>(0); // Estado para o tempo de início do formulário
|
||||
const [nameFocused, setNameFocused] = useState<boolean>(false);
|
||||
const [emailFocused, setEmailFocused] = useState<boolean>(false);
|
||||
const [phoneFocused, setPhoneFocused] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setStartTime(Date.now()); // Registra o tempo de carregamento do componente
|
||||
}, []);
|
||||
|
||||
if (!data) {
|
||||
return null; // Ou um componente de carregamento/erro
|
||||
}
|
||||
|
||||
const getGreetingMessageWithUserName = () => {
|
||||
const hour = new Date().getHours();
|
||||
const dayPart =
|
||||
hour >= 8 && hour < 12
|
||||
? "manhã"
|
||||
: hour >= 12 && hour < 18
|
||||
? "tarde"
|
||||
: "noite";
|
||||
|
||||
return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva.
|
||||
|
||||
Meu nome é ${formData.userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
|
||||
|
||||
Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva.
|
||||
|
||||
Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos.
|
||||
|
||||
Atenciosamente,
|
||||
${formData.userName}`;
|
||||
};
|
||||
|
||||
const generateAdvancedMessage = () => {
|
||||
if (formData.customMessage) return formData.customMessage;
|
||||
|
||||
// Saudação profissional
|
||||
let message = `Prezado Evandro,\n\n`;
|
||||
|
||||
// Informações do contato
|
||||
message += `Meu nome é ${formData.userName}`;
|
||||
if (formData.userCompany) {
|
||||
message += `, represento a empresa ${formData.userCompany}`;
|
||||
}
|
||||
message += `.\n`;
|
||||
|
||||
if (formData.howFound) {
|
||||
message += `Conheci seu trabalho através de ${formData.howFound}.\n`;
|
||||
}
|
||||
|
||||
message += `\n`;
|
||||
|
||||
// Detalhes do projeto
|
||||
message += `=== DETALHES DO PROJETO ===\n`;
|
||||
message += `Tipo de projeto: `;
|
||||
if (formData.projectType === "outro" && formData.otherProjectType) {
|
||||
message += `${formData.otherProjectType}\n`;
|
||||
} else if (formData.projectType) {
|
||||
message += `${formData.projectType}\n`;
|
||||
} else {
|
||||
message += `Não especificado\n`;
|
||||
}
|
||||
|
||||
if (formData.desiredDeadline) {
|
||||
message += `Prazo desejado: ${formData.desiredDeadline}\n`;
|
||||
}
|
||||
|
||||
if (formData.budgetValue) {
|
||||
message += `Orçamento disponível: ${formData.budgetValue}\n`;
|
||||
}
|
||||
|
||||
if (formData.technicalComplexity) {
|
||||
message += `Complexidade técnica estimada: ${formData.technicalComplexity}\n`;
|
||||
}
|
||||
|
||||
if (formData.hasInternalTeam) {
|
||||
message += `Equipe técnica interna: ${
|
||||
formData.hasInternalTeam === "sim"
|
||||
? "Sim"
|
||||
: formData.hasInternalTeam === "nao"
|
||||
? "Não"
|
||||
: "Parcialmente"
|
||||
}\n`;
|
||||
}
|
||||
|
||||
message += `\n`;
|
||||
|
||||
// Contatos
|
||||
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
|
||||
if (formData.userEmail) {
|
||||
message += `📧 Email: ${formData.userEmail}\n`;
|
||||
}
|
||||
if (formData.userPhone) {
|
||||
message += `📱 Telefone/WhatsApp: ${formData.userPhone}\n`;
|
||||
}
|
||||
|
||||
message += `\n`;
|
||||
|
||||
// Mensagem final
|
||||
message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`;
|
||||
message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`;
|
||||
message += `Atenciosamente,\n`;
|
||||
message += `${formData.userName}`;
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const handleInputChange =
|
||||
(field: keyof typeof formData) =>
|
||||
(
|
||||
e: ChangeEvent<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>,
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
// Clear specific errors if the user starts typing
|
||||
if (e.target.value.trim() !== "") {
|
||||
if (field === "userName") setNameError(null);
|
||||
if (field === "userEmail") setEmailError(null);
|
||||
if (field === "userPhone") setPhoneError(null);
|
||||
if (field === "otherProjectType") setOtherProjectTypeError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameFocus = () => setNameFocused(true);
|
||||
const handleEmailFocus = () => setEmailFocused(true);
|
||||
const handlePhoneFocus = () => setPhoneFocused(true);
|
||||
|
||||
const generateWhatsAppUrl = () =>
|
||||
buildWhatsAppUrl(
|
||||
formType === "advanced"
|
||||
? generateAdvancedMessage()
|
||||
: formData.customMessage || getGreetingMessageWithUserName(),
|
||||
);
|
||||
|
||||
const generateEmailUrl = () => {
|
||||
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
||||
const body = encodeURIComponent(
|
||||
formType === "advanced"
|
||||
? generateAdvancedMessage()
|
||||
: `Nome: ${formData.userName}\nEmail: ${formData.userEmail}\nTelefone: ${formData.userPhone}\nEmpresa/Posição: ${formData.userCompany}\nComo conheceu: ${formData.howFound}\n\nTipo de Projeto: ${formData.projectType}\nPrazo Desejado: ${formData.desiredDeadline}\nOrçamento Disponível: ${formData.budgetValue}\nComplexidade Técnica: ${formData.technicalComplexity}\nTem equipe interna: ${formData.hasInternalTeam}\n\nMensagem: ${
|
||||
formData.customMessage || getGreetingMessageWithUserName()
|
||||
}`,
|
||||
);
|
||||
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
let hasError = false;
|
||||
if (formData.userName.trim() === "") {
|
||||
setNameError("Por favor, insira seu nome.");
|
||||
hasError = true;
|
||||
} else {
|
||||
setNameError(null);
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.userEmail)) {
|
||||
setEmailError("Por favor, insira um email válido.");
|
||||
hasError = true;
|
||||
} else {
|
||||
setEmailError(null);
|
||||
}
|
||||
|
||||
if (formData.userPhone.trim() === "") {
|
||||
setPhoneError("Por favor, insira seu telefone.");
|
||||
hasError = true;
|
||||
} else {
|
||||
setPhoneError(null);
|
||||
}
|
||||
|
||||
if (
|
||||
formData.projectType === "outro" &&
|
||||
formData.otherProjectType.trim() === ""
|
||||
) {
|
||||
setOtherProjectTypeError("Por favor, especifique o tipo de projeto.");
|
||||
hasError = true;
|
||||
} else {
|
||||
setOtherProjectTypeError(null);
|
||||
}
|
||||
|
||||
// Validação Honeypot
|
||||
if (honeypot) {
|
||||
console.log("Honeypot preenchido, provável bot.");
|
||||
return; // Ignora a submissão
|
||||
}
|
||||
|
||||
// UX FIX: Validação de tempo com feedback ou limite menor
|
||||
const timeElapsed = Date.now() - startTime;
|
||||
// Reduzido para 1000ms para evitar falsos positivos com autofill
|
||||
if (timeElapsed < 1000) {
|
||||
console.log("Formulário preenchido muito rápido, provável bot.");
|
||||
// Opcional: Mostrar erro para humano real que digita muito rápido
|
||||
// alert("Por favor, verifique os dados antes de enviar.");
|
||||
return; // Ignora a submissão
|
||||
}
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
// Verificação de interação mínima
|
||||
if (!nameFocused || !emailFocused || !phoneFocused) {
|
||||
// MENTORIA: Isso pode bloquear usuários de Screen Readers ou Autofill agressivo.
|
||||
// Considere remover ou tornar apenas um aviso no console.
|
||||
console.warn(
|
||||
"Campos principais não foram focados, verificando integridade.",
|
||||
);
|
||||
}
|
||||
|
||||
if (contactMethod === "whatsapp") {
|
||||
window.open(generateWhatsAppUrl(), "_blank");
|
||||
} else {
|
||||
window.open(generateEmailUrl(), "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const getCtaMessage = () => {
|
||||
if (formData.projectType === "consultoria") {
|
||||
return "Agendar Consultoria Gratuita";
|
||||
}
|
||||
if (
|
||||
formData.budgetValue &&
|
||||
parseFloat(formData.budgetValue.replace(/\D/g, "")) < 2000
|
||||
) {
|
||||
return "Discutir Soluções de Baixo Custo";
|
||||
}
|
||||
if (
|
||||
formData.projectType &&
|
||||
formData.desiredDeadline &&
|
||||
formData.budgetValue
|
||||
) {
|
||||
return "Receber Proposta Personalizada";
|
||||
}
|
||||
return "Enviar Formulário";
|
||||
};
|
||||
|
||||
const getEstimatedValue = () => {
|
||||
let hours = 0;
|
||||
let hourlyRate = 55; // Taxa base R$ 55/h
|
||||
|
||||
// Estimativa de horas baseada no tipo de projeto
|
||||
switch (formData.projectType) {
|
||||
case "web-app":
|
||||
hours += 80; // Aplicação web completa
|
||||
break;
|
||||
case "landing-page":
|
||||
hours += 16; // Landing page simples
|
||||
break;
|
||||
case "mvp":
|
||||
hours += 40; // MVP básico
|
||||
break;
|
||||
case "iot-app":
|
||||
hours += 120; // Aplicação IoT complexa
|
||||
break;
|
||||
case "integracao":
|
||||
hours += 32; // Integração de sistemas
|
||||
break;
|
||||
case "manutencao":
|
||||
hours += 8; // Manutenção pontual
|
||||
break;
|
||||
case "consultoria":
|
||||
hours += 4; // Consultoria inicial
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Ajuste de horas baseado no prazo
|
||||
switch (formData.desiredDeadline) {
|
||||
case "1-3-weeks":
|
||||
hours *= 1.3; // Urgência aumenta horas
|
||||
break;
|
||||
case "1-2-months":
|
||||
hours *= 1.1;
|
||||
break;
|
||||
case "6-plus-months":
|
||||
hours *= 0.9; // Prazo longo pode reduzir horas
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Ajuste de horas baseado na complexidade
|
||||
switch (formData.technicalComplexity) {
|
||||
case "alta":
|
||||
hours *= 1.4;
|
||||
hourlyRate = 65; // Taxa maior para alta complexidade
|
||||
break;
|
||||
case "muito-alta":
|
||||
hours *= 1.7;
|
||||
hourlyRate = 70; // Taxa máxima para muito alta complexidade
|
||||
break;
|
||||
case "media":
|
||||
hourlyRate = 50; // Taxa média
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (formData.hasInternalTeam === "nao") {
|
||||
hours *= 1.2; // Sem equipe interna = mais horas necessárias
|
||||
}
|
||||
|
||||
const totalValue = hours * hourlyRate;
|
||||
|
||||
if (totalValue > 0) {
|
||||
return `Estimativa: ${Math.round(
|
||||
hours,
|
||||
)}h × R$ ${hourlyRate}/h = R$ ${totalValue.toFixed(0)}`;
|
||||
}
|
||||
|
||||
return "Estimativa: Solicite orçamento personalizado";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<h3 className="text-2xl font-semibold mb-6 bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
|
||||
Preencha o Formulário
|
||||
</h3>
|
||||
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormType("simple")}
|
||||
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
|
||||
formType === "simple"
|
||||
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Formulário Simples
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormType("advanced")}
|
||||
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
|
||||
formType === "advanced"
|
||||
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Formulário Avançado
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formType === "simple" ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Campo Honeypot */}
|
||||
<div style={{ display: "none" }}>
|
||||
<label htmlFor="honeypot">Não preencha este campo</label>
|
||||
<input
|
||||
type="text"
|
||||
id="honeypot"
|
||||
name="honeypot"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Formulário Simples: Apenas Nome */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="simple-user-name"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Seu Nome:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="simple-user-name"
|
||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
||||
nameError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="Seu nome"
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange("userName")}
|
||||
onFocus={handleNameFocus}
|
||||
required
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-red-400 text-xs mt-1">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão de Contato para o formulário simples */}
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-green-600 hover:bg-green-700 text-white shadow-lg shadow-green-600/25"
|
||||
onClick={() => window.open(generateWhatsAppUrl(), "_blank")}
|
||||
>
|
||||
Enviar Mensagem via WhatsApp
|
||||
</button>
|
||||
</motion.div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Campo Honeypot */}
|
||||
<div style={{ display: "none" }}>
|
||||
<label htmlFor="honeypot">Não preencha este campo</label>
|
||||
<input
|
||||
type="text"
|
||||
id="honeypot"
|
||||
name="honeypot"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dados Básicos */}
|
||||
<div>
|
||||
<h4 className="text-xl font-semibold mb-4 text-gray-200">
|
||||
Dados Básicos
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="user-name"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Nome Completo:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user-name"
|
||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
||||
nameError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="Seu nome completo"
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange("userName")}
|
||||
onFocus={handleNameFocus}
|
||||
required
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-red-400 text-xs mt-1">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="user-email"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Email Corporativo:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="user-email"
|
||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
||||
emailError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="[email protected]"
|
||||
value={formData.userEmail}
|
||||
onChange={handleInputChange("userEmail")}
|
||||
onFocus={handleEmailFocus}
|
||||
required
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-red-400 text-xs mt-1">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="user-phone"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
WhatsApp/Telefone:
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="user-phone"
|
||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
||||
phoneError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="(XX) XXXXX-XXXX"
|
||||
value={formData.userPhone}
|
||||
onChange={handleInputChange("userPhone")}
|
||||
onFocus={handlePhoneFocus}
|
||||
required
|
||||
/>
|
||||
{phoneError && (
|
||||
<p className="text-red-400 text-xs mt-1">{phoneError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="user-company"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Empresa/Posição:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user-company"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="Nome da empresa, sua posição"
|
||||
value={formData.userCompany}
|
||||
onChange={handleInputChange("userCompany")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="how-found"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Como conheceu meu trabalho?
|
||||
</label>
|
||||
<select
|
||||
id="how-found"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: "right 12px center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={formData.howFound}
|
||||
onChange={handleInputChange("howFound")}
|
||||
>
|
||||
{" "}
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione uma opção
|
||||
</option>
|
||||
<option value="linkedin" className="bg-gray-800 text-white">
|
||||
LinkedIn
|
||||
</option>
|
||||
<option value="github" className="bg-gray-800 text-white">
|
||||
GitHub
|
||||
</option>
|
||||
<option value="indicacao" className="bg-gray-800 text-white">
|
||||
Indicação
|
||||
</option>
|
||||
<option value="pesquisa" className="bg-gray-800 text-white">
|
||||
Pesquisa Online
|
||||
</option>
|
||||
<option value="outro" className="bg-gray-800 text-white">
|
||||
Outro
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tile de Expandir/Recolher */}
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-4 rounded-lg bg-white/5 border border-white/10 text-white cursor-pointer hover:bg-white/10 transition-colors duration-300 mb-6 flex justify-between items-center"
|
||||
>
|
||||
<h4 className="text-xl font-semibold text-gray-200">
|
||||
{isExpanded
|
||||
? "Detalhes do Projeto (Recolher)"
|
||||
: "Detalhes do Projeto (Expandir)"}
|
||||
</h4>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform duration-300 ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
||||
isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
|
||||
} space-y-6`}
|
||||
>
|
||||
{/* Conteúdo do Formulário Avançado */}
|
||||
{/* Sobre o Projeto */}
|
||||
<div>
|
||||
<h4 className="text-xl font-semibold mb-4 text-gray-200">
|
||||
Sobre o Projeto
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="project-type"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Tipo de Projeto:
|
||||
</label>
|
||||
<select
|
||||
id="project-type"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: "right 12px center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={formData.projectType}
|
||||
onChange={handleInputChange("projectType")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione o tipo
|
||||
</option>
|
||||
<option value="web-app" className="bg-gray-800 text-white">
|
||||
Aplicação Web Completa
|
||||
</option>
|
||||
<option
|
||||
value="landing-page"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
Landing Page
|
||||
</option>
|
||||
<option value="mvp" className="bg-gray-800 text-white">
|
||||
MVP (Produto Mínimo Viável)
|
||||
</option>
|
||||
<option value="iot-app" className="bg-gray-800 text-white">
|
||||
Aplicação IoT
|
||||
</option>
|
||||
<option
|
||||
value="integracao"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
Integração de Sistemas
|
||||
</option>
|
||||
<option
|
||||
value="manutencao"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
Manutenção/Ajustes
|
||||
</option>
|
||||
<option
|
||||
value="consultoria"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
Consultoria/Mentoria
|
||||
</option>
|
||||
<option value="outro" className="bg-gray-800 text-white">
|
||||
Outro
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.projectType === "outro" && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="other-project-type"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Qual outro tipo de projeto?
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="other-project-type"
|
||||
className={`w-full p-3 rounded-lg bg-white/5 border ${
|
||||
otherProjectTypeError
|
||||
? "border-red-500"
|
||||
: "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="Ex: Desenvolvimento de Plugin, Automação de Processos"
|
||||
value={formData.otherProjectType}
|
||||
onChange={handleInputChange("otherProjectType")}
|
||||
required
|
||||
/>
|
||||
{otherProjectTypeError && (
|
||||
<p className="text-red-400 text-xs mt-1">
|
||||
{otherProjectTypeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="desired-deadline"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Prazo Desejado:
|
||||
</label>
|
||||
<select
|
||||
id="desired-deadline"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: "right 12px center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={formData.desiredDeadline}
|
||||
onChange={handleInputChange("desiredDeadline")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione o prazo
|
||||
</option>
|
||||
<option
|
||||
value="1-3-weeks"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
1-3 Semanas
|
||||
</option>
|
||||
<option
|
||||
value="1-2-months"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
1-2 Meses
|
||||
</option>
|
||||
<option
|
||||
value="2-4-months"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
2-4 Meses
|
||||
</option>
|
||||
<option
|
||||
value="4-6-months"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
4-6 Meses
|
||||
</option>
|
||||
<option
|
||||
value="6-plus-months"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
Mais de 6 Meses
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="budget-value"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Orçamento Disponível (R$):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="budget-value"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="Ex: 5000, 10000, A Combinar"
|
||||
value={formData.budgetValue}
|
||||
onChange={handleInputChange("budgetValue")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="technical-complexity"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Complexidade Técnica Estimada:
|
||||
</label>
|
||||
<select
|
||||
id="technical-complexity"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: "right 12px center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={formData.technicalComplexity}
|
||||
onChange={handleInputChange("technicalComplexity")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione a complexidade
|
||||
</option>
|
||||
<option value="baixa" className="bg-gray-800 text-white">
|
||||
Baixa
|
||||
</option>
|
||||
<option value="media" className="bg-gray-800 text-white">
|
||||
Média
|
||||
</option>
|
||||
<option value="alta" className="bg-gray-800 text-white">
|
||||
Alta
|
||||
</option>
|
||||
<option
|
||||
value="muito-alta"
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
Muito Alta
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="has-internal-team"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Já tem equipe técnica interna?
|
||||
</label>
|
||||
<select
|
||||
id="has-internal-team"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
|
||||
backgroundPosition: "right 12px center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={formData.hasInternalTeam}
|
||||
onChange={handleInputChange("hasInternalTeam")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione
|
||||
</option>
|
||||
<option value="sim" className="bg-gray-800 text-white">
|
||||
Sim
|
||||
</option>
|
||||
<option value="nao" className="bg-gray-800 text-white">
|
||||
Não
|
||||
</option>
|
||||
<option value="parcial" className="bg-gray-800 text-white">
|
||||
Parcialmente
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Message Input */}
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="custom-message"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
Mensagem Adicional (Opcional):
|
||||
</label>
|
||||
<textarea
|
||||
id="custom-message"
|
||||
rows={4}
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="Descreva seu projeto ou suas necessidades aqui..."
|
||||
value={formData.customMessage}
|
||||
onChange={handleInputChange("customMessage")}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Button */}
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
|
||||
>
|
||||
{getCtaMessage()}
|
||||
</button>
|
||||
</motion.div>
|
||||
{formData.projectType &&
|
||||
formData.desiredDeadline &&
|
||||
formData.budgetValue && (
|
||||
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
|
||||
{getEstimatedValue()}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, MessageCircle } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
|
||||
interface ContactInfoProps {
|
||||
onWhatsAppClick: () => void;
|
||||
}
|
||||
|
||||
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
||||
const { data } = usePortfolioData();
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
if (!data) {
|
||||
return null; // Ou um componente de carregamento/erro
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-6 text-blue-400">
|
||||
Entre em Contato
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={`mailto:${data.contact.email}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
|
||||
wrapperClassName="block w-full"
|
||||
strength={0.1}
|
||||
>
|
||||
<div className="p-3 rounded-full bg-blue-500/20">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">Email</p>
|
||||
<p className="text-gray-400">{data.contact.email}</p>
|
||||
</div>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
onClick={onWhatsAppClick}
|
||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
|
||||
wrapperClassName="block w-full"
|
||||
strength={0.1}
|
||||
>
|
||||
<div className="p-3 rounded-full bg-green-500/20">
|
||||
<MessageCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">WhatsApp</p>
|
||||
<p className="text-gray-400">{whatsAppDisplay}</p>
|
||||
</div>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={data.contact.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
|
||||
wrapperClassName="block w-full"
|
||||
strength={0.1}
|
||||
>
|
||||
<div className="p-3 rounded-full bg-blue-500/20">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">LinkedIn</p>
|
||||
<p className="text-gray-400">
|
||||
{data.contact.linkedin.replace("https://www.", "")}
|
||||
</p>
|
||||
</div>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={data.contact.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
|
||||
wrapperClassName="block w-full"
|
||||
strength={0.1}
|
||||
>
|
||||
<div className="p-3 rounded-full bg-gray-500/20">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">GitHub</p>
|
||||
<p className="text-gray-400">
|
||||
{data.contact.github.replace("https://github.com/", "")}
|
||||
</p>
|
||||
</div>
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
|
||||
Localização
|
||||
</h3>
|
||||
<p className="text-gray-300">📍 {data.contact.location}</p>
|
||||
<p className="text-gray-400 mt-2">
|
||||
Disponível para projetos remotos e trabalho presencial na região.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeline de Resposta (Placeholder) */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
|
||||
Timeline de Resposta
|
||||
</h3>
|
||||
<p className="text-gray-300">
|
||||
Em breve, uma timeline detalhada do processo de contato e resposta.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
return {
|
||||
motion: {
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
function makeExperience(index: number, featured: boolean): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period: `${2020 + index} - Present`,
|
||||
location: "Remote",
|
||||
type: index % 2 === 0 ? "Software" : "Hardware",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Experience featured filtering", () => {
|
||||
it("renders exactly the featured experiences", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.boolean(), { minLength: 1, maxLength: 8 }),
|
||||
(featuredFlags) => {
|
||||
const experiences = featuredFlags.map((featured, index) =>
|
||||
makeExperience(index, featured),
|
||||
);
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
const expectedCount = featuredFlags.filter(Boolean).length;
|
||||
|
||||
expect(articles).toHaveLength(expectedCount);
|
||||
const articleText = articles.map(
|
||||
(article) => article.textContent ?? "",
|
||||
);
|
||||
featuredFlags.forEach((featured, index) => {
|
||||
const title = `Experience ${index}`;
|
||||
expect(articleText.some((text) => text.includes(title))).toBe(
|
||||
featured,
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
},
|
||||
),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an empty experience list safely", () => {
|
||||
const { container } = render(<Experience experiences={[]} />);
|
||||
|
||||
expect(container.querySelector("section")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
return {
|
||||
motion: {
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
function makeExperience(
|
||||
index: number,
|
||||
year: number,
|
||||
period = `${year} - Present`,
|
||||
): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period,
|
||||
location: "Remote",
|
||||
type: "Software",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Experience chronological ordering", () => {
|
||||
it("sorts featured experiences by the first year in descending order", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1990, max: 2035 }), {
|
||||
minLength: 1,
|
||||
maxLength: 8,
|
||||
}),
|
||||
(years) => {
|
||||
const experiences = years.map((year, index) =>
|
||||
makeExperience(index, year),
|
||||
);
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
const renderedYears = Array.from(
|
||||
container.querySelectorAll("article .ds-kicker"),
|
||||
).map((element) => Number.parseInt(element.textContent ?? "0", 10));
|
||||
const expectedExperiences = [...experiences].sort(
|
||||
(a, b) =>
|
||||
Number.parseInt(b.period, 10) - Number.parseInt(a.period, 10),
|
||||
);
|
||||
const expectedYears = expectedExperiences.map((experience) =>
|
||||
Number.parseInt(experience.period, 10),
|
||||
);
|
||||
const renderedTitles = Array.from(
|
||||
container.querySelectorAll("article h3"),
|
||||
).map((element) => element.textContent);
|
||||
|
||||
expect(renderedYears).toEqual(expectedYears);
|
||||
expect(renderedTitles).toEqual(
|
||||
expectedExperiences.map((experience) => experience.title),
|
||||
);
|
||||
unmount();
|
||||
},
|
||||
),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts a period range by its first year", () => {
|
||||
const experiences = [
|
||||
makeExperience(1, 2022, "2022 - 2024"),
|
||||
makeExperience(2, 2024, "2024 - Present"),
|
||||
makeExperience(3, 2021, "2021 - 2023"),
|
||||
];
|
||||
const { container } = render(<Experience experiences={experiences} />);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("article .ds-kicker")).map(
|
||||
(element) => element.textContent,
|
||||
),
|
||||
).toEqual(["2024 - Present", "2022 - 2024", "2021 - 2023"]);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
return {
|
||||
motion: {
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
function makeExperience(index: number): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period: `${2020 + index} - Present`,
|
||||
location: "Remote",
|
||||
type: index % 2 === 0 ? "Software" : "Hardware",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Experience heading hierarchy", () => {
|
||||
it("renders one h2 section heading and h3 headings for featured cards", () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 8 }), (count) => {
|
||||
const experiences = Array.from({ length: count }, (_, index) =>
|
||||
makeExperience(index),
|
||||
);
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
|
||||
expect(headings[0]?.tagName).toBe("H2");
|
||||
expect(container.querySelector("section > h2")).toHaveTextContent(
|
||||
"Experiência em engenharia de software e sistemas críticos",
|
||||
);
|
||||
expect(container.querySelectorAll("section > h2")).toHaveLength(1);
|
||||
expect(container.querySelectorAll("article h3")).toHaveLength(count);
|
||||
expect(
|
||||
new Set(
|
||||
Array.from(container.querySelectorAll("article h3")).map(
|
||||
(heading) => heading.textContent,
|
||||
),
|
||||
),
|
||||
).toEqual(new Set(experiences.map((experience) => experience.title)));
|
||||
expect(headings.some((heading) => heading.tagName === "H4")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
unmount();
|
||||
}),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no card headings when no experiences are supplied", () => {
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
expect(container.querySelector("section > h2")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("article h3")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
return {
|
||||
motion: {
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({
|
||||
children,
|
||||
id,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
id?: string;
|
||||
title?: string;
|
||||
}) => (
|
||||
<section id={id}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => null,
|
||||
}));
|
||||
|
||||
function experience({
|
||||
id,
|
||||
title,
|
||||
period,
|
||||
featured,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
period: string;
|
||||
featured: boolean;
|
||||
}): PortfolioExperience {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
company: "Empresa",
|
||||
period,
|
||||
location: "Remoto",
|
||||
type: "Software",
|
||||
description: "Descrição",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Experience", () => {
|
||||
it("renders only featured experiences from newest to oldest", () => {
|
||||
render(
|
||||
<Experience
|
||||
experiences={[
|
||||
experience({
|
||||
id: "hidden",
|
||||
title: "Oculta",
|
||||
period: "2026 - Atual",
|
||||
featured: false,
|
||||
}),
|
||||
experience({
|
||||
id: "older",
|
||||
title: "Mais antiga",
|
||||
period: "2019 - 2021",
|
||||
featured: true,
|
||||
}),
|
||||
experience({
|
||||
id: "recent",
|
||||
title: "Mais recente",
|
||||
period: "2024 - Atual",
|
||||
featured: true,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Oculta" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("article")
|
||||
.map(
|
||||
(article) =>
|
||||
within(article).getByRole("heading", { level: 3 }).textContent,
|
||||
),
|
||||
).toEqual(["Mais recente", "Mais antiga"]);
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,9 @@ export default function Experience({
|
||||
<div className="max-w-[720px]">
|
||||
<div className="ds-kicker">{exp.period}</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title" data-reel="left">{exp.title}</h3>
|
||||
<h3 className="ds-card-title" data-reel="left">
|
||||
{exp.title}
|
||||
</h3>
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
|
||||
{exp.company} • {exp.type}
|
||||
</p>
|
||||
@@ -87,7 +89,9 @@ export default function Experience({
|
||||
<div className="ds-metric">{exp.location}</div>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy" data-reel="right">{exp.description}</p>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{exp.description}
|
||||
</p>
|
||||
|
||||
<ul className="ds-list">
|
||||
{exp.achievements?.map((achievement) => (
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Property 9: ARIA Labels for Links", () => {
|
||||
it("should have descriptive aria-label attributes for all social media links", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link in the Footer component, it should have
|
||||
* a descriptive aria-label attribute that identifies the platform and action.
|
||||
* This ensures accessibility for users with assistive technologies.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed, testing static component
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links (they have SVG icons and are in the Connect section)
|
||||
const socialLinks = container.querySelectorAll(
|
||||
"footer a[aria-label]",
|
||||
);
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each social link has a non-empty, descriptive aria-label
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
|
||||
// aria-label must exist and be non-empty
|
||||
if (!ariaLabel || ariaLabel.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// aria-label should be descriptive (at least 5 characters)
|
||||
// and should describe an action (contain words like "Visit", "View", "Go to", etc.)
|
||||
return ariaLabel.length >= 5;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have unique aria-label for each social platform", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any set of social media links, each should have a unique
|
||||
* aria-label that identifies the specific platform. This ensures users can
|
||||
* distinguish between different social links when using screen readers.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links with aria-labels
|
||||
const socialLinks = container.querySelectorAll("footer a[aria-label]");
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Collect all aria-labels
|
||||
const ariaLabels = socialLinksWithSvg.map((link) =>
|
||||
link.getAttribute("aria-label"),
|
||||
);
|
||||
|
||||
// Verify we have aria-labels
|
||||
if (ariaLabels.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all aria-labels are unique
|
||||
const uniqueLabels = new Set(ariaLabels);
|
||||
return uniqueLabels.size === ariaLabels.length;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have aria-label that identifies both platform and action", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link, the aria-label should contain both
|
||||
* the platform name (GitHub, LinkedIn, etc.) and an action verb (Visit, View, etc.).
|
||||
* This provides complete context for assistive technology users.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links
|
||||
const socialLinks = container.querySelectorAll("footer a[aria-label]");
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Common action verbs for social links
|
||||
const actionVerbs = ["visit", "view", "go to", "open", "see", "check"];
|
||||
|
||||
// Check each link has an action verb in its aria-label
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
|
||||
if (!ariaLabel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerLabel = ariaLabel.toLowerCase();
|
||||
|
||||
// Check if aria-label contains at least one action verb
|
||||
return actionVerbs.some((verb) => lowerLabel.includes(verb));
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain aria-labels across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any number of component renders, social links should consistently
|
||||
* maintain their aria-label attributes. This verifies that accessibility attributes
|
||||
* are not accidentally removed during re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
|
||||
(numRenders) => {
|
||||
let allRendersValid = true;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<Footer />);
|
||||
|
||||
// Find social links
|
||||
const socialLinks = container.querySelectorAll(
|
||||
"footer a[aria-label]",
|
||||
);
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter(
|
||||
(link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
},
|
||||
);
|
||||
|
||||
// Check each link has a valid aria-label
|
||||
socialLinksWithSvg.forEach((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
|
||||
if (!ariaLabel || ariaLabel.trim().length < 5) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should have aria-label for every social link with target="_blank"', () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social link that opens in a new tab (target="_blank"),
|
||||
* it must have an aria-label. This ensures users with assistive technologies
|
||||
* are informed about where the link leads before activating it.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all links that open in new tab
|
||||
const newTabLinks = container.querySelectorAll(
|
||||
'footer a[target="_blank"]',
|
||||
);
|
||||
|
||||
// Verify we found links
|
||||
if (newTabLinks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each new tab link should have an aria-label
|
||||
return Array.from(newTabLinks).every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
return ariaLabel !== null && ariaLabel.trim().length > 0;
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have empty or whitespace-only aria-labels", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link with an aria-label attribute,
|
||||
* the value should not be empty or contain only whitespace. Empty
|
||||
* aria-labels provide no accessibility benefit and can confuse screen readers.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links
|
||||
const socialLinks = container.querySelectorAll("footer a[aria-label]");
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that no aria-label is empty or whitespace-only
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
return ariaLabel !== null && ariaLabel.trim().length > 0;
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have aria-label that matches or relates to the link title", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link with both aria-label and title attributes,
|
||||
* the aria-label should provide context that relates to the title (platform name).
|
||||
* This ensures consistency between visual tooltips and screen reader announcements.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links with both aria-label and title
|
||||
const socialLinks = container.querySelectorAll(
|
||||
"footer a[aria-label][title]",
|
||||
);
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// If no links have both attributes, that's okay (not all implementations require title)
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check that aria-label relates to title
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
const title = link.getAttribute("title");
|
||||
|
||||
if (!ariaLabel || !title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// aria-label should contain the platform name from title
|
||||
// or title should be contained in aria-label
|
||||
const lowerLabel = ariaLabel.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
|
||||
return (
|
||||
lowerLabel.includes(lowerTitle) || lowerTitle.includes(lowerLabel)
|
||||
);
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Footer responsive structure", () => {
|
||||
it("keeps the responsive design-system structure across renders", () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 5 }), (renderCount) => {
|
||||
for (let index = 0; index < renderCount; index += 1) {
|
||||
const { container, unmount } = render(<Footer />);
|
||||
const footer = container.querySelector("footer");
|
||||
const grid = footer?.querySelector(".ds-grid-2");
|
||||
|
||||
expect(footer).toHaveClass("ds-section");
|
||||
expect(
|
||||
footer?.querySelector(".ds-section-shell"),
|
||||
).toBeInTheDocument();
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid?.querySelectorAll(":scope > .ds-card")).toHaveLength(2);
|
||||
|
||||
unmount();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the mobile-first grid primitive instead of the legacy grid contract", () => {
|
||||
const { container } = render(<Footer />);
|
||||
const grid = container.querySelector(".ds-grid-2");
|
||||
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid).not.toHaveClass("grid-cols-1");
|
||||
expect(grid).not.toHaveClass("lg:grid-cols-3");
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import fc from "fast-check";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Property 2: Social Links Open in New Tab", () => {
|
||||
it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any social media link in the Footer component, it should have
|
||||
* target="_blank" and rel="noopener noreferrer" attributes to open in a new tab
|
||||
* securely. This ensures consistent behavior across all social links and prevents
|
||||
* security vulnerabilities (tabnabbing).
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed, testing static component
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links (they are within the "Connect" section)
|
||||
const socialLinks = container.querySelectorAll(
|
||||
'a[aria-label*="Visit"]',
|
||||
);
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each social link
|
||||
let allLinksValid = true;
|
||||
|
||||
socialLinks.forEach((link) => {
|
||||
const target = link.getAttribute("target");
|
||||
const rel = link.getAttribute("rel");
|
||||
|
||||
// Both attributes must be present and correct
|
||||
if (target !== "_blank" || rel !== "noopener noreferrer") {
|
||||
allLinksValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return allLinksValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should consistently apply security attributes across all social link variations", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any subset of social links rendered, each link should maintain
|
||||
* the security attributes (target="_blank" and rel="noopener noreferrer").
|
||||
* This property verifies that the security pattern is consistently applied
|
||||
* regardless of which social platforms are included.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 10 }), // Random seed for variation
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Get all anchor tags in the footer
|
||||
const allLinks = container.querySelectorAll("footer a");
|
||||
|
||||
// Filter to only social links (those with SVG icons and aria-labels about visiting profiles)
|
||||
const socialLinks = Array.from(allLinks).filter((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
const hasSvg = link.querySelector("svg") !== null;
|
||||
return ariaLabel && ariaLabel.includes("Visit") && hasSvg;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that ALL social links have the required attributes
|
||||
return socialLinks.every((link) => {
|
||||
const target = link.getAttribute("target");
|
||||
const rel = link.getAttribute("rel");
|
||||
return target === "_blank" && rel === "noopener noreferrer";
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not apply new tab attributes to non-social links", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: Only social media links should have target="_blank". Other links
|
||||
* in the footer (like email) should not force new tab behavior. This ensures
|
||||
* the new tab behavior is intentionally applied only where appropriate.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Get all anchor tags in the footer
|
||||
const allLinks = container.querySelectorAll("footer a");
|
||||
|
||||
// Separate social links from other links
|
||||
const socialLinks: Element[] = [];
|
||||
const otherLinks: Element[] = [];
|
||||
|
||||
allLinks.forEach((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
const hasSvg = link.querySelector("svg") !== null;
|
||||
|
||||
if (ariaLabel && ariaLabel.includes("Visit") && hasSvg) {
|
||||
socialLinks.push(link);
|
||||
} else {
|
||||
otherLinks.push(link);
|
||||
}
|
||||
});
|
||||
|
||||
// All social links should have target="_blank"
|
||||
const allSocialLinksHaveTarget = socialLinks.every((link) => {
|
||||
return link.getAttribute("target") === "_blank";
|
||||
});
|
||||
|
||||
// Non-social links (like email) should NOT have target="_blank"
|
||||
const noOtherLinksHaveTarget = otherLinks.every((link) => {
|
||||
return link.getAttribute("target") !== "_blank";
|
||||
});
|
||||
|
||||
return allSocialLinksHaveTarget && noOtherLinksHaveTarget;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain security attributes after multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any number of component renders, social links should consistently
|
||||
* maintain their security attributes. This verifies that the attributes are not
|
||||
* accidentally removed or modified during re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
|
||||
(numRenders) => {
|
||||
let allRendersValid = true;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<Footer />);
|
||||
|
||||
// Find social links
|
||||
const socialLinks = container.querySelectorAll(
|
||||
'a[aria-label*="Visit"]',
|
||||
);
|
||||
|
||||
// Check each link
|
||||
socialLinks.forEach((link) => {
|
||||
const target = link.getAttribute("target");
|
||||
const rel = link.getAttribute("rel");
|
||||
|
||||
if (target !== "_blank" || rel !== "noopener noreferrer") {
|
||||
allRendersValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have both target and rel attributes present together", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any social link, if target="_blank" is present, then
|
||||
* rel="noopener noreferrer" must also be present. These attributes work
|
||||
* together to provide secure new tab behavior, and one without the other
|
||||
* creates a security vulnerability.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all links with target="_blank"
|
||||
const linksWithTarget = container.querySelectorAll(
|
||||
'footer a[target="_blank"]',
|
||||
);
|
||||
|
||||
// Verify we found links
|
||||
if (linksWithTarget.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each link with target="_blank" must have rel="noopener noreferrer"
|
||||
return Array.from(linksWithTarget).every((link) => {
|
||||
const rel = link.getAttribute("rel");
|
||||
return rel === "noopener noreferrer";
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have exactly the expected number of social links with security attributes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: The Footer should render a specific number of social links (based on
|
||||
* the socialLinks array), and all of them should have the security attributes.
|
||||
* This ensures no links are accidentally missing the attributes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social links with security attributes
|
||||
const secureLinks = container.querySelectorAll(
|
||||
'footer a[target="_blank"][rel="noopener noreferrer"]',
|
||||
);
|
||||
|
||||
// Find all social links (with aria-label containing "Visit")
|
||||
const allSocialLinks = container.querySelectorAll(
|
||||
'footer a[aria-label*="Visit"]',
|
||||
);
|
||||
|
||||
// The number of secure links should equal the number of social links
|
||||
// This ensures ALL social links have the security attributes
|
||||
return (
|
||||
secureLinks.length === allSocialLinks.length &&
|
||||
allSocialLinks.length > 0
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -72,18 +72,7 @@ describe("Footer social links", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer structure", () => {
|
||||
it("uses the current design-system shell and two-card grid", () => {
|
||||
const { container } = render(<Footer />);
|
||||
const footer = container.querySelector("footer");
|
||||
|
||||
expect(footer).toHaveClass("ds-section");
|
||||
expect(footer?.querySelector(".ds-section-shell")).toBeInTheDocument();
|
||||
expect(footer?.querySelector(".ds-grid-2")).toBeInTheDocument();
|
||||
expect(footer?.querySelectorAll(":scope .ds-card")).toHaveLength(2);
|
||||
expect(footer?.querySelectorAll("h3")).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("Footer copyright", () => {
|
||||
it("renders the current year without hardcoded copyright text", () => {
|
||||
render(<Footer />);
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ComponentProps } from "react";
|
||||
import GoogleAnalytics from "./GoogleAnalytics";
|
||||
|
||||
jest.mock("next/script", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
strategy: _strategy,
|
||||
...props
|
||||
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
||||
}));
|
||||
|
||||
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
||||
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
||||
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S";
|
||||
|
||||
type AnalyticsWindow = Window & {
|
||||
dataLayer?: IArguments[];
|
||||
gtag?: (...args: unknown[]) => void;
|
||||
[GA_DISABLE_KEY]?: boolean;
|
||||
};
|
||||
|
||||
const analyticsWindow = window as AnalyticsWindow;
|
||||
|
||||
function consentUpdates(gtag: jest.Mock) {
|
||||
return gtag.mock.calls.filter(
|
||||
([command, action]) => command === "consent" && action === "update",
|
||||
);
|
||||
}
|
||||
|
||||
function setCookie(value: string) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
|
||||
document.cookie = value;
|
||||
}
|
||||
|
||||
describe("GoogleAnalytics consent", () => {
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||
document.getElementById("google-analytics-script")?.remove();
|
||||
localStorage.clear();
|
||||
setCookie("_ga=; Max-Age=0; Path=/");
|
||||
setCookie("_ga_PORTFOLIO=; Max-Age=0; Path=/");
|
||||
setCookie("portfolio_session=; Max-Age=0; Path=/");
|
||||
analyticsWindow.dataLayer = undefined;
|
||||
analyticsWindow.gtag = undefined;
|
||||
analyticsWindow[GA_DISABLE_KEY] = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("waits for a decision before loading analytics", async () => {
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Aceitar analytics" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleDescription();
|
||||
expect(
|
||||
screen.queryByTestId("google-analytics-script"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists acceptance and enables analytics without advertising consent", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Aceitar analytics" }),
|
||||
);
|
||||
|
||||
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
||||
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
|
||||
"src",
|
||||
GA_SRC,
|
||||
);
|
||||
const dataLayer = analyticsWindow.dataLayer;
|
||||
expect(dataLayer).toHaveLength(4);
|
||||
expect(dataLayer?.every((entry) => !Array.isArray(entry))).toBe(true);
|
||||
expect(Array.from(dataLayer?.[0] ?? [])).toEqual([
|
||||
"consent",
|
||||
"default",
|
||||
{
|
||||
analytics_storage: "denied",
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(Array.from(dataLayer?.[1] ?? [])).toEqual(["js", expect.any(Date)]);
|
||||
expect(Array.from(dataLayer?.[2] ?? [])).toEqual([
|
||||
"consent",
|
||||
"update",
|
||||
{
|
||||
analytics_storage: "granted",
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(Array.from(dataLayer?.[3] ?? [])).toEqual([
|
||||
"config",
|
||||
"G-QTT9QT0T3S",
|
||||
{
|
||||
allow_ad_personalization_signals: false,
|
||||
anonymize_ip: true,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Preferências de analytics" }),
|
||||
).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("persists refusal and keeps analytics absent after reload", async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstRender = render(<GoogleAnalytics />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Recusar analytics" }),
|
||||
);
|
||||
|
||||
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||
expect(
|
||||
screen.queryByTestId("google-analytics-script"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
firstRender.unmount();
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Preferências de analytics" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByTestId("google-analytics-script"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps preferences reachable and fully revokes analytics only", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(CONSENT_KEY, "granted");
|
||||
setCookie("_ga=main; Path=/");
|
||||
setCookie("_ga_PORTFOLIO=property; Path=/");
|
||||
setCookie("portfolio_session=keep; Path=/");
|
||||
const gtag = jest.fn();
|
||||
analyticsWindow.gtag = gtag;
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
await screen.findByTestId("google-analytics-script");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Preferências de analytics" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
|
||||
|
||||
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
||||
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
|
||||
expect(consentUpdates(gtag)).toContainEqual([
|
||||
"consent",
|
||||
"update",
|
||||
{
|
||||
analytics_storage: "denied",
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
ad_personalization: "denied",
|
||||
},
|
||||
]);
|
||||
expect(document.cookie).not.toContain("_ga=");
|
||||
expect(document.cookie).not.toContain("_ga_PORTFOLIO=");
|
||||
expect(document.cookie).toContain("portfolio_session=keep");
|
||||
expect(
|
||||
screen.queryByTestId("google-analytics-script"),
|
||||
).not.toBeInTheDocument();
|
||||
const preferencesButton = screen.getByRole("button", {
|
||||
name: "Preferências de analytics",
|
||||
});
|
||||
expect(preferencesButton).toBeVisible();
|
||||
expect(preferencesButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it("restores focus after closing or applying opened preferences", async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.setItem(CONSENT_KEY, "denied");
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
const preferencesButton = await screen.findByRole("button", {
|
||||
name: "Preferências de analytics",
|
||||
});
|
||||
await user.click(preferencesButton);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Fechar preferências" }),
|
||||
);
|
||||
expect(preferencesButton).toHaveFocus();
|
||||
|
||||
await user.click(preferencesButton);
|
||||
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
|
||||
expect(preferencesButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it("fails closed when local storage is unavailable", async () => {
|
||||
const user = userEvent.setup();
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("storage blocked");
|
||||
});
|
||||
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("storage blocked");
|
||||
});
|
||||
render(<GoogleAnalytics />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Aceitar analytics" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("google-analytics-script"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import Script from "next/script";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
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";
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const deniedConsent = {
|
||||
analytics_storage: "denied",
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
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);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getGtag(): Gtag {
|
||||
window.dataLayer ??= [];
|
||||
const dataLayer = window.dataLayer;
|
||||
if (!window.gtag) {
|
||||
window.gtag = function gtag() {
|
||||
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
|
||||
dataLayer.push(arguments);
|
||||
};
|
||||
}
|
||||
return window.gtag;
|
||||
}
|
||||
|
||||
function removeAnalyticsLoader() {
|
||||
document.getElementById("google-analytics-script")?.remove();
|
||||
}
|
||||
|
||||
function writeCookie(value: string) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
|
||||
document.cookie = value;
|
||||
}
|
||||
function expireAnalyticsCookies() {
|
||||
const cookieNames = new Set(
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((cookie) => cookie.split("=")[0]?.trim())
|
||||
.filter(
|
||||
(name): name is string =>
|
||||
name === "_ga" || Boolean(name?.startsWith("_ga_")),
|
||||
),
|
||||
);
|
||||
const { hostname } = window.location;
|
||||
const domains =
|
||||
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
|
||||
? new Set([hostname, SITE_DOMAIN])
|
||||
: new Set<string>();
|
||||
|
||||
for (const name of cookieNames) {
|
||||
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
|
||||
writeCookie(expiration);
|
||||
for (const domain of domains) {
|
||||
writeCookie(`${expiration}; Domain=${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const panelClassName =
|
||||
"fixed bottom-sp-4 left-sp-4 right-sp-4 z-[9980] ml-auto max-w-[560px] border border-[var(--slash)] bg-[var(--ink)] p-sp-5 text-white shadow-[8px_8px_0_var(--slash)] sm:left-auto";
|
||||
const buttonClassName =
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center border px-sp-4 py-sp-2 font-mono text-[11px] font-bold uppercase tracking-[0.08em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||
|
||||
function GoogleAnalyticsConsent() {
|
||||
const [consent, setConsent] = useState<AnalyticsConsent | null>();
|
||||
const [preferencesOpen, setPreferencesOpen] = useState(false);
|
||||
const configuredRef = useRef(false);
|
||||
const preferencesButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const enableAnalytics = useCallback(() => {
|
||||
const gtag = getGtag();
|
||||
window[GA_DISABLE_KEY] = false;
|
||||
if (!configuredRef.current) {
|
||||
gtag("consent", "default", deniedConsent);
|
||||
gtag("js", new Date());
|
||||
configuredRef.current = true;
|
||||
}
|
||||
gtag("consent", "update", {
|
||||
...deniedConsent,
|
||||
analytics_storage: "granted",
|
||||
});
|
||||
gtag("config", GA_MEASUREMENT_ID, {
|
||||
allow_ad_personalization_signals: false,
|
||||
anonymize_ip: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedConsent = readConsent();
|
||||
if (storedConsent === "granted") enableAnalytics();
|
||||
setConsent(storedConsent);
|
||||
}, [enableAnalytics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (consent !== "granted" && consent !== undefined) {
|
||||
removeAnalyticsLoader();
|
||||
}
|
||||
}, [consent]);
|
||||
|
||||
function closePreferences() {
|
||||
setPreferencesOpen(false);
|
||||
preferencesButtonRef.current?.focus();
|
||||
}
|
||||
|
||||
function acceptAnalytics() {
|
||||
if (!persistConsent("granted")) return;
|
||||
enableAnalytics();
|
||||
setConsent("granted");
|
||||
closePreferences();
|
||||
}
|
||||
|
||||
function denyAnalytics() {
|
||||
persistConsent("denied");
|
||||
setConsent("denied");
|
||||
closePreferences();
|
||||
}
|
||||
|
||||
function revokeAnalytics() {
|
||||
persistConsent("denied");
|
||||
window[GA_DISABLE_KEY] = true;
|
||||
window.gtag?.("consent", "update", deniedConsent);
|
||||
expireAnalyticsCookies();
|
||||
setConsent("denied");
|
||||
closePreferences();
|
||||
}
|
||||
|
||||
if (consent === undefined) return null;
|
||||
|
||||
if (consent === null) {
|
||||
return (
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="analytics-consent-title"
|
||||
aria-describedby="analytics-consent-description"
|
||||
className={panelClassName}
|
||||
>
|
||||
<p
|
||||
id="analytics-consent-title"
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
|
||||
>
|
||||
{"Privacidade // Analytics"}
|
||||
</p>
|
||||
<p
|
||||
id="analytics-consent-description"
|
||||
className="mt-sp-2 max-w-[48ch] text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Você decide se permite métricas anônimas para melhorar este site.
|
||||
Publicidade e personalização continuam desativadas.
|
||||
</p>
|
||||
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
|
||||
onClick={acceptAnalytics}
|
||||
>
|
||||
Aceitar analytics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
|
||||
onClick={denyAnalytics}
|
||||
>
|
||||
Recusar analytics
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script src="/sys/libs/vendor-utils.js" strategy="lazyOnload" />
|
||||
<Script id="google-analytics" strategy="lazyOnload">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||
transport_url: window.location.origin + '/sys/api/vitals',
|
||||
cookie_flags: 'SameSite=None;Secure',
|
||||
allow_ad_personalization_signals: false,
|
||||
anonymize_ip: true,
|
||||
page_location: window.location.href,
|
||||
page_title: document.title
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
{consent === "granted" && (
|
||||
<Script
|
||||
id="google-analytics-script"
|
||||
data-testid="google-analytics-script"
|
||||
src={GA_SCRIPT_SRC}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
ref={preferencesButtonRef}
|
||||
aria-expanded={preferencesOpen}
|
||||
aria-controls="analytics-preferences"
|
||||
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
|
||||
onClick={() => setPreferencesOpen((open) => !open)}
|
||||
>
|
||||
Preferências de analytics
|
||||
</button>
|
||||
{preferencesOpen && (
|
||||
<section
|
||||
id="analytics-preferences"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="analytics-preferences-title"
|
||||
aria-describedby="analytics-preferences-description"
|
||||
className={panelClassName}
|
||||
>
|
||||
<p
|
||||
id="analytics-preferences-title"
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
|
||||
>
|
||||
{"Preferências // Analytics"}
|
||||
</p>
|
||||
<p
|
||||
id="analytics-preferences-description"
|
||||
className="mt-sp-2 text-[13px] leading-6 text-white/85"
|
||||
>
|
||||
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
|
||||
Dados de publicidade permanecem negados.
|
||||
</p>
|
||||
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
|
||||
{consent === "granted" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
|
||||
onClick={revokeAnalytics}
|
||||
>
|
||||
Revogar analytics
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
|
||||
onClick={acceptAnalytics}
|
||||
>
|
||||
Aceitar analytics
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
|
||||
onClick={closePreferences}
|
||||
>
|
||||
Fechar preferências
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (process.env.NODE_ENV !== "production") return null;
|
||||
return <GoogleAnalyticsConsent />;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function Header() {
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
className="site-header-menu-toggle flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||
className="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"}
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
const DynamicComponent = ({ variant }: { variant?: string }) => (
|
||||
<div data-testid="hero-scene" data-variant={variant} />
|
||||
);
|
||||
const DynamicComponent = () => <div data-testid="hero-scene" />;
|
||||
DynamicComponent.displayName = "MockHeroScene";
|
||||
return DynamicComponent;
|
||||
});
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
const MotionStateContext = React.createContext({
|
||||
initial: undefined,
|
||||
animate: undefined,
|
||||
});
|
||||
|
||||
const resolveOpacity = (
|
||||
value: unknown,
|
||||
variants: Record<string, { opacity?: number }> | undefined,
|
||||
) => {
|
||||
const state: unknown =
|
||||
typeof value === "string" ? variants?.[value] : value;
|
||||
if (
|
||||
state &&
|
||||
typeof state === "object" &&
|
||||
"opacity" in state &&
|
||||
typeof state.opacity === "number"
|
||||
) {
|
||||
return state.opacity;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
@@ -22,26 +41,47 @@ jest.mock("motion/react", () => {
|
||||
children,
|
||||
initial,
|
||||
animate,
|
||||
variants,
|
||||
transition: _transition,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
variants: _variants,
|
||||
transition: _transition,
|
||||
style,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: unknown;
|
||||
variants?: Record<string, { opacity?: number }>;
|
||||
style?: Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const dataProps = {
|
||||
...(initial !== undefined
|
||||
? { "data-initial": JSON.stringify(initial) }
|
||||
: {}),
|
||||
...(animate !== undefined
|
||||
? { "data-animate": JSON.stringify(animate) }
|
||||
: {}),
|
||||
};
|
||||
const inherited = React.useContext(MotionStateContext);
|
||||
const effectiveInitial =
|
||||
initial === undefined ? inherited.initial : initial;
|
||||
const effectiveAnimate =
|
||||
animate === undefined ? inherited.animate : animate;
|
||||
const opacity = resolveOpacity(
|
||||
effectiveInitial === false ? effectiveAnimate : effectiveInitial,
|
||||
variants,
|
||||
);
|
||||
const element = React.createElement(
|
||||
tag,
|
||||
{
|
||||
...props,
|
||||
style: opacity === undefined ? style : { ...style, opacity },
|
||||
},
|
||||
children,
|
||||
);
|
||||
|
||||
return React.createElement(tag, { ...props, ...dataProps }, children);
|
||||
return React.createElement(
|
||||
MotionStateContext.Provider,
|
||||
{
|
||||
value: {
|
||||
initial: effectiveInitial,
|
||||
animate: effectiveAnimate,
|
||||
},
|
||||
},
|
||||
element,
|
||||
);
|
||||
};
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
@@ -96,46 +136,31 @@ function mockMatchMedia(matches: boolean) {
|
||||
describe("Hero reduced motion", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(true);
|
||||
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps essential hero content visible with reduced motion enabled", async () => {
|
||||
it("shows active copy and actions immediately without animation timers", () => {
|
||||
render(<Hero />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(
|
||||
const visibleContent = [
|
||||
screen.getByText(/Disponível para novas oportunidades/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 1,
|
||||
name: /engenharia de software para sistemas/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
screen.getByRole("button", {
|
||||
name: /conversar sobre uma oportunidade/i,
|
||||
}),
|
||||
screen.getByRole("link", { name: /ver casos selecionados/i }),
|
||||
screen.getByRole("link", { name: /download cv/i }),
|
||||
];
|
||||
|
||||
it("keeps motion blocks declarative instead of requiring 3D", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const rootMotionBlock = container.querySelector(
|
||||
"div[data-initial][data-animate]",
|
||||
);
|
||||
expect(rootMotionBlock).toBeInTheDocument();
|
||||
expect(rootMotionBlock?.getAttribute("data-initial")).toContain("hidden");
|
||||
expect(rootMotionBlock?.getAttribute("data-animate")).toContain("visible");
|
||||
for (const element of visibleContent) {
|
||||
expect(element).toBeVisible();
|
||||
}
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
const DynamicComponent = ({ variant }: { variant?: string }) => (
|
||||
<div data-testid="hero-scene" data-variant={variant} />
|
||||
);
|
||||
const DynamicComponent = () => <div data-testid="hero-scene" />;
|
||||
DynamicComponent.displayName = "MockHeroScene";
|
||||
return DynamicComponent;
|
||||
});
|
||||
@@ -82,7 +80,6 @@ function mockMatchMedia(matches = false) {
|
||||
describe("Hero", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(false);
|
||||
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
});
|
||||
|
||||
@@ -90,54 +87,68 @@ describe("Hero", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the command center hero and primary actions", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector("[data-hero-variant]")).toHaveAttribute(
|
||||
"data-hero-variant",
|
||||
"command-center",
|
||||
),
|
||||
);
|
||||
it("renders the published command-center content and primary actions", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Disponível para novas oportunidades"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 1,
|
||||
name: /engenharia de software para sistemas/i,
|
||||
name: "Engenharia de software para sistemas que precisam operar de verdade.",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
|
||||
screen.getByRole("button", {
|
||||
name: "Conversar sobre uma oportunidade",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /ver casos selecionados/i }),
|
||||
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/i })).toHaveAttribute(
|
||||
expect(screen.getByRole("link", { name: "Download CV" })).toHaveAttribute(
|
||||
"href",
|
||||
"/Profile.pdf",
|
||||
);
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps social links, proof content and WhatsApp CTA", async () => {
|
||||
it("keeps social destinations, operational proof and WhatsApp action", () => {
|
||||
render(<Hero />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/Disponível para novas oportunidades/i),
|
||||
).toBeInTheDocument(),
|
||||
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/evandrodevbr",
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("GitHub")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("LinkedIn")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Operação/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clareza/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:[email protected]",
|
||||
);
|
||||
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/i }),
|
||||
screen.getByRole("button", {
|
||||
name: "Conversar sobre uma oportunidade",
|
||||
}),
|
||||
);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://wa.me/"),
|
||||
|
||||
+119
-472
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { motion, useReducedMotion, type Variants } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
|
||||
@@ -15,33 +15,6 @@ const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
type HeroSceneVariant =
|
||||
| "command"
|
||||
| "launch"
|
||||
| "rescue"
|
||||
| "signal"
|
||||
| "diagnostic"
|
||||
| "portrait";
|
||||
|
||||
type HeroVariant = {
|
||||
id: string;
|
||||
weight: number;
|
||||
scene: HeroSceneVariant;
|
||||
layout: "default" | "visual-first" | "split" | "report" | "portrait";
|
||||
status: string;
|
||||
chip: string;
|
||||
eyebrow: string;
|
||||
headline: string;
|
||||
subtitle: string;
|
||||
cta: string;
|
||||
whatsappMessage: string;
|
||||
sideLabel: string;
|
||||
sideTitle: string;
|
||||
sideText: string;
|
||||
points: string[];
|
||||
proof: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
href: "https://github.com/evandrodevbr",
|
||||
@@ -70,236 +43,42 @@ const SOCIAL_LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
const HERO_VARIANTS: HeroVariant[] = [
|
||||
{
|
||||
id: "command-center",
|
||||
weight: 22,
|
||||
scene: "command",
|
||||
layout: "default",
|
||||
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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "launch-sequence",
|
||||
weight: 18,
|
||||
scene: "launch",
|
||||
layout: "visual-first",
|
||||
status: "Launch sequence ready",
|
||||
chip: "Discovery → build → deploy",
|
||||
eyebrow: "MVP, produto e entrega em produção",
|
||||
headline:
|
||||
"Tire seu MVP do plano e coloque em produção com base técnica real.",
|
||||
subtitle:
|
||||
"Transformo escopo solto em sequência de lançamento: o que validar, o que construir primeiro e como publicar sem criar uma dívida técnica logo no início.",
|
||||
cta: "Lançar meu MVP",
|
||||
whatsappMessage:
|
||||
"Olá! Quero lançar um MVP com uma base técnica segura. Podemos conversar?",
|
||||
sideLabel: "Sequência de lançamento",
|
||||
sideTitle: "Do primeiro corte ao deploy.",
|
||||
sideText:
|
||||
"A conversa define prioridade, risco e o menor caminho confiável para colocar a primeira versão no ar.",
|
||||
points: [
|
||||
"Recorte de MVP sem inflar escopo.",
|
||||
"Entrega incremental com critério de produção.",
|
||||
"Deploy e continuidade pensados desde o início.",
|
||||
],
|
||||
proof: [
|
||||
{ label: "01", value: "Diagnóstico do objetivo e gargalo principal." },
|
||||
{ label: "02", value: "Plano de construção com risco visível." },
|
||||
{ label: "03", value: "Publicação e próximo ciclo sem perder contexto." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "rescue-split",
|
||||
weight: 16,
|
||||
scene: "rescue",
|
||||
layout: "split",
|
||||
status: "Rescue mode available",
|
||||
chip: "Legado, instabilidade e projeto travado",
|
||||
eyebrow: "Rescue técnico para sistemas que precisam voltar a andar",
|
||||
headline:
|
||||
"Código confuso, entrega travada ou sistema instável? Vamos organizar.",
|
||||
subtitle:
|
||||
"Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos técnico em plano executável.",
|
||||
cta: "Destravar meu projeto",
|
||||
whatsappMessage:
|
||||
"Olá! Tenho um projeto travado ou instável e quero destravar. Podemos conversar?",
|
||||
sideLabel: "Antes / depois técnico",
|
||||
sideTitle: "Do caos operacional para uma rota clara.",
|
||||
sideText:
|
||||
"O primeiro objetivo não é reescrever tudo. É encontrar o menor corte seguro que devolve controle e previsibilidade.",
|
||||
points: [
|
||||
"Leitura rápida de fluxo, risco e acoplamento.",
|
||||
"Priorização do que destrava sem reescrever tudo.",
|
||||
"Correções com critério para não trocar um bug por outro.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Antes",
|
||||
value: "Backlog confuso, bugs recorrentes e pouca previsibilidade.",
|
||||
},
|
||||
{
|
||||
label: "Intervenção",
|
||||
value: "Mapa de risco, cortes pequenos e decisão técnica clara.",
|
||||
},
|
||||
{ label: "Depois", value: "Entrega volta a andar com menos improviso." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "iot-signal-map",
|
||||
weight: 15,
|
||||
scene: "signal",
|
||||
layout: "default",
|
||||
status: "Signal map active",
|
||||
chip: "Hardware + software + operação",
|
||||
eyebrow: "Produtos conectados, telemetria e integrações críticas",
|
||||
headline:
|
||||
"Conecte dispositivo, backend e interface em uma operação confiável.",
|
||||
subtitle:
|
||||
"Atuo na fronteira que costuma quebrar primeiro: sinal físico, API, painel, dados e rotina operacional funcionando como um único produto.",
|
||||
cta: "Conectar meu produto",
|
||||
whatsappMessage:
|
||||
"Olá! Quero conectar hardware, software e operação em um produto confiável. Podemos conversar?",
|
||||
sideLabel: "Mapa de sinais",
|
||||
sideTitle: "Dados, comandos e painel no mesmo fluxo.",
|
||||
sideText:
|
||||
"A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integração em produto.",
|
||||
points: [
|
||||
"Contratos claros entre dispositivo, API e interface.",
|
||||
"Telemetria e dashboards com contexto operacional.",
|
||||
"Base pronta para automação, IA ou analytics.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Entrada",
|
||||
value: "Sensores, dispositivos, eventos ou sistemas externos.",
|
||||
},
|
||||
{
|
||||
label: "Meio",
|
||||
value: "APIs, WebSockets, filas, banco e observabilidade.",
|
||||
},
|
||||
{
|
||||
label: "Saída",
|
||||
value: "Painel, operação, alerta e decisão de produto.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "diagnostic-report",
|
||||
weight: 15,
|
||||
scene: "diagnostic",
|
||||
layout: "report",
|
||||
status: "Diagnostic report open",
|
||||
chip: "Arquitetura antes de mais código",
|
||||
eyebrow: "Diagnóstico técnico para decidir melhor",
|
||||
headline: "Antes de escrever mais código, descubra o próximo passo certo.",
|
||||
subtitle:
|
||||
"Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da próxima decisão cara.",
|
||||
cta: "Agendar diagnóstico",
|
||||
whatsappMessage:
|
||||
"Olá! Quero um diagnóstico técnico para decidir o próximo passo do meu sistema. Podemos conversar?",
|
||||
sideLabel: "Relatório técnico",
|
||||
sideTitle: "Risco, arquitetura e rota de ação.",
|
||||
sideText:
|
||||
"Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho técnico.",
|
||||
points: [
|
||||
"Mapa claro dos gargalos e dependências.",
|
||||
"Trade-offs traduzidos para decisão de negócio.",
|
||||
"Plano de transição sem promessa mágica.",
|
||||
],
|
||||
proof: [
|
||||
{ label: "Risco", value: "O que pode quebrar, atrasar ou custar caro." },
|
||||
{
|
||||
label: "Arquitetura",
|
||||
value: "Onde simplificar, separar ou estabilizar primeiro.",
|
||||
},
|
||||
{ label: "Ação", value: "Próximo corte de implementação com critério." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cinematic-portrait",
|
||||
weight: 14,
|
||||
scene: "portrait",
|
||||
layout: "portrait",
|
||||
status: "Direct technical partner",
|
||||
chip: "Conversa direta com quem implementa",
|
||||
eyebrow: "Engenharia sênior, ownership e execução",
|
||||
headline: "Um parceiro técnico para transformar complexidade em entrega.",
|
||||
subtitle:
|
||||
"Você não precisa apenas de tela bonita ou código isolado. Precisa de alguém que entenda contexto, escolha o corte certo e acompanhe até o sistema operar.",
|
||||
cta: "Falar com Evandro",
|
||||
whatsappMessage:
|
||||
"Olá Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.",
|
||||
sideLabel: "Founder-led engineering",
|
||||
sideTitle: "Menos repasse, mais ownership.",
|
||||
sideText:
|
||||
"A conversa é comigo: contexto técnico, prioridade, risco e caminho de entrega sem perder informação entre camadas.",
|
||||
points: [
|
||||
"Atendimento consultivo com leitura técnica real.",
|
||||
"Do produto ao backend sem perder contexto.",
|
||||
"Execução com critério, não só código por demanda.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Perfil",
|
||||
value: "Engenharia full-stack com base em sistemas e integrações.",
|
||||
},
|
||||
{
|
||||
label: "Postura",
|
||||
value: "Clareza, autonomia e decisão técnica pragmática.",
|
||||
},
|
||||
{
|
||||
label: "Acesso",
|
||||
value: "WhatsApp direto para conversa técnica inicial.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const LAYOUT_CLASSES: Record<HeroVariant["layout"], string> = {
|
||||
default:
|
||||
"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",
|
||||
"visual-first":
|
||||
"grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10",
|
||||
split:
|
||||
"grid grid-cols-[minmax(0,1fr)] items-stretch gap-sp-8 lg:grid-cols-2 lg:gap-sp-10",
|
||||
report:
|
||||
"grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10",
|
||||
portrait:
|
||||
"grid grid-cols-[minmax(0,1fr)] items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
|
||||
};
|
||||
const HERO_CONTENT = {
|
||||
status: "Disponível para novas oportunidades",
|
||||
chip: "Garuva · Joinville · Curitiba · Remoto",
|
||||
eyebrow:
|
||||
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
|
||||
headline:
|
||||
"Engenharia de software para sistemas que precisam operar de verdade.",
|
||||
subtitle:
|
||||
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
|
||||
cta: "Conversar sobre uma oportunidade",
|
||||
whatsappMessage:
|
||||
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
|
||||
sideLabel: "Contato profissional direto",
|
||||
sideTitle: "Procurando um engenheiro da computação para o seu time?",
|
||||
sideText:
|
||||
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
|
||||
points: [
|
||||
"Engenharia full-stack com base em sistemas e integrações.",
|
||||
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
|
||||
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Operação",
|
||||
value: "Fluxo pensado para discovery, build, deploy e sustentação.",
|
||||
},
|
||||
{
|
||||
label: "Clareza",
|
||||
value: "Menos ruído entre produto, engenharia e decisão técnica.",
|
||||
},
|
||||
{
|
||||
label: "Resultado",
|
||||
value: "Base pronta para operar, evoluir e receber próximo ciclo.",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
function getGreeting(message: string) {
|
||||
const hour = new Date().getHours();
|
||||
@@ -308,155 +87,9 @@ function getGreeting(message: string) {
|
||||
return message;
|
||||
}
|
||||
|
||||
function SideVisual({
|
||||
variant,
|
||||
shouldReduceMotion,
|
||||
}: {
|
||||
variant: HeroVariant;
|
||||
shouldReduceMotion: boolean;
|
||||
}) {
|
||||
if (variant.scene === "command") {
|
||||
return (
|
||||
<div className="mb-sp-6 space-y-sp-4">
|
||||
{/* Starfield decorativo: grade uniforme com jitter cobrindo o card inteiro */}
|
||||
<HeroStarfield />
|
||||
{/* autoRotate off em reduced-motion: globo vira âncora estática */}
|
||||
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
|
||||
<TelemetryStrip reducedMotion={shouldReduceMotion} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.scene === "launch") {
|
||||
return (
|
||||
<div className="mb-sp-6 grid gap-sp-3">
|
||||
{variant.proof.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="cinematic-glass flex gap-sp-4 p-sp-4"
|
||||
>
|
||||
<span className="font-mono text-[11px] text-[var(--slash)]">
|
||||
0{index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[12px] font-bold uppercase tracking-[0.08em]">
|
||||
{item.label}
|
||||
</div>
|
||||
<p className="mt-sp-1 text-[12px] leading-6 text-[rgba(244,242,237,.7)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.scene === "rescue") {
|
||||
return (
|
||||
<div className="mb-sp-6 grid gap-sp-4 sm:grid-cols-2">
|
||||
<div className="border border-[rgba(255,80,80,.28)] bg-[rgba(255,80,80,.05)] p-sp-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(255,160,160,.9)]">
|
||||
Antes
|
||||
</div>
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.72)]">
|
||||
Bugs recorrentes, escopo nebuloso e medo de mexer.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-[rgba(204,255,0,.35)] bg-[rgba(204,255,0,.06)] p-sp-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]">
|
||||
Depois
|
||||
</div>
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.86)]">
|
||||
Rota clara, cortes menores e entrega voltando a andar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.scene === "signal") {
|
||||
return (
|
||||
<div className="relative mb-sp-6 h-40 overflow-hidden border border-[rgba(255,255,255,.08)] bg-[rgba(255,255,255,.03)]">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 420 160"
|
||||
>
|
||||
<path
|
||||
d="M28 116 C108 18 190 142 284 56 S380 36 400 28"
|
||||
fill="none"
|
||||
stroke="rgba(204,255,0,.75)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{[28, 150, 284, 400].map((x, index) => (
|
||||
<circle
|
||||
key={x}
|
||||
cx={x}
|
||||
cy={[116, 78, 56, 28][index]}
|
||||
r="6"
|
||||
fill="#ccff00"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.scene === "diagnostic") {
|
||||
return (
|
||||
<div className="mb-sp-6 border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] p-sp-5">
|
||||
<div className="mb-sp-4 flex items-center justify-between border-b border-[rgba(255,255,255,.08)] pb-sp-3 font-mono text-[10px] uppercase tracking-[0.12em]">
|
||||
<span>Risk report</span>
|
||||
<span className="text-[var(--slash)]">Actionable</span>
|
||||
</div>
|
||||
<div className="space-y-sp-3">
|
||||
{variant.proof.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-[12px] text-[rgba(244,242,237,.78)]">
|
||||
<span>{item.label}</span>
|
||||
<span className="text-[var(--slash)]">mapped</span>
|
||||
</div>
|
||||
<div className="mt-sp-2 h-px bg-[linear-gradient(90deg,var(--slash),rgba(255,255,255,.08))]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.scene === "portrait") {
|
||||
return (
|
||||
<div className="mb-sp-6 border border-[rgba(204,255,0,.22)] bg-[radial-gradient(circle_at_70%_20%,rgba(204,255,0,.14),transparent_35%),rgba(255,255,255,.035)] p-sp-5">
|
||||
<div className="font-display text-[clamp(2.5rem,8vw,5rem)] font-extrabold leading-none tracking-[-0.08em] text-[rgba(244,242,237,.95)]">
|
||||
EFJ
|
||||
</div>
|
||||
<div className="mt-sp-4 font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
Engenharia com contato direto
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-sp-6 grid gap-sp-3 font-mono text-[11px] uppercase tracking-[0.08em]">
|
||||
{["deploy pipeline", "api health", "production ready"].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center justify-between border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] px-sp-4 py-sp-3"
|
||||
>
|
||||
<span>{item}</span>
|
||||
<span className="text-[var(--slash)]">ok</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const variant = HERO_VARIANTS[0];
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion() ?? true;
|
||||
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true);
|
||||
|
||||
@@ -465,7 +98,6 @@ export default function Hero() {
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
setShouldReduceMotion(reducedMotion);
|
||||
|
||||
if (
|
||||
!shouldLoadHeroScene({
|
||||
@@ -494,17 +126,19 @@ export default function Hero() {
|
||||
|
||||
const handleWhatsAppClick = () => {
|
||||
window.open(
|
||||
buildWhatsAppUrl(getGreeting(variant.whatsappMessage)),
|
||||
buildWhatsAppUrl(getGreeting(HERO_CONTENT.whatsappMessage)),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
const heroContainer: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
hidden: shouldReduceMotion ? { opacity: 1 } : { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.3 },
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { staggerChildren: 0.1, delayChildren: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -512,47 +146,39 @@ export default function Hero() {
|
||||
|
||||
const heroItem: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
? { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
: { opacity: 0, y: 40, filter: "blur(10px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 1, ease: easeOut },
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 1, ease: easeOut },
|
||||
},
|
||||
};
|
||||
|
||||
const heroItemSlow: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
? { opacity: 1, y: 0, filter: "blur(0px)" }
|
||||
: { opacity: 0, y: 60, filter: "blur(12px)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 1.2, ease: easeOut },
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 1.2, ease: easeOut },
|
||||
},
|
||||
};
|
||||
|
||||
const contentOrder =
|
||||
variant.layout === "visual-first" || variant.layout === "report"
|
||||
? "lg:order-2"
|
||||
: "";
|
||||
const panelOrder =
|
||||
variant.layout === "visual-first" || variant.layout === "report"
|
||||
? "lg:order-1"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef}
|
||||
id="home"
|
||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
data-hero-variant={variant.id}
|
||||
>
|
||||
{shouldRenderHeroScene && (
|
||||
<HeroScene isVisible={isHeroVisible} variant={variant.scene} />
|
||||
)}
|
||||
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||
@@ -568,12 +194,12 @@ export default function Hero() {
|
||||
{/* 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="hidden"
|
||||
initial={shouldReduceMotion ? false : "hidden"}
|
||||
animate="visible"
|
||||
variants={heroContainer}
|
||||
className={LAYOUT_CLASSES[variant.layout]}
|
||||
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] ${contentOrder}`.trim()}>
|
||||
<div className="max-w-[800px]">
|
||||
<motion.div
|
||||
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
|
||||
variants={heroItem}
|
||||
@@ -604,11 +230,11 @@ export default function Hero() {
|
||||
}
|
||||
/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
|
||||
{variant.status}
|
||||
{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)]">
|
||||
{variant.chip}
|
||||
{HERO_CONTENT.chip}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -616,42 +242,46 @@ export default function Hero() {
|
||||
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
|
||||
variants={heroItem}
|
||||
>
|
||||
{variant.eyebrow}
|
||||
{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}
|
||||
>
|
||||
{variant.headline}
|
||||
{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}
|
||||
>
|
||||
{variant.subtitle}
|
||||
{HERO_CONTENT.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.ul
|
||||
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
|
||||
variants={heroItem}
|
||||
>
|
||||
{variant.points.map((point, i) => (
|
||||
{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
|
||||
? { opacity: 0 }
|
||||
? false
|
||||
: { opacity: 0, y: 30, scale: 0.95 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.8 + i * 0.1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 0.8,
|
||||
delay: 0.8 + i * 0.1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
>
|
||||
<span className="mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--slash)] shadow-[0_0_8px_rgba(204,255,0,0.5)]" />
|
||||
<span>{point}</span>
|
||||
@@ -668,17 +298,15 @@ export default function Hero() {
|
||||
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"
|
||||
data-cursor="View"
|
||||
strength={0.25}
|
||||
>
|
||||
<span className="relative z-10">{variant.cta}</span>
|
||||
<span className="relative z-10">{HERO_CONTENT.cta}</span>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
className="cinematic-cta-secondary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#f4f2ed] no-underline"
|
||||
data-cursor="View"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver casos selecionados
|
||||
@@ -741,40 +369,53 @@ export default function Hero() {
|
||||
</div>
|
||||
|
||||
<motion.aside
|
||||
className={`cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8 ${panelOrder}`.trim()}
|
||||
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
? false
|
||||
: { opacity: 0, x: 60, rotateY: -8, transformPerspective: 1000 }
|
||||
}
|
||||
animate={
|
||||
animate={{ opacity: 1, x: 0, rotateY: 0 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, x: 0, rotateY: 0 }
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.2,
|
||||
delay: 0.5,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
transition={{ duration: 1.2, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
|
||||
initial={{ scaleX: 0 }}
|
||||
initial={shouldReduceMotion ? false : { scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.5,
|
||||
delay: 1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
<SideVisual
|
||||
variant={variant}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
<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)]">
|
||||
{variant.sideLabel}
|
||||
{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]">
|
||||
{variant.sideTitle}
|
||||
{HERO_CONTENT.sideTitle}
|
||||
</h2>
|
||||
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
|
||||
{variant.sideText}
|
||||
{HERO_CONTENT.sideText}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -795,21 +436,25 @@ export default function Hero() {
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-6 space-y-sp-5">
|
||||
{variant.proof.map((item, index) => (
|
||||
{HERO_CONTENT.proof.map((item, index) => (
|
||||
<motion.article
|
||||
key={`${variant.id}-${item.label}`}
|
||||
key={item.label}
|
||||
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
? false
|
||||
: { opacity: 0, y: 20, filter: "blur(6px)" }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.8 + index * 0.12,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
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}
|
||||
@@ -826,13 +471,15 @@ export default function Hero() {
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 2, duration: 1 }}
|
||||
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-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
|
||||
<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" />
|
||||
@@ -847,7 +494,7 @@ export default function Hero() {
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
getFeaturedProjects,
|
||||
type PortfolioProjectView,
|
||||
} from "@/lib/portfolio-data";
|
||||
import Projects from "./Projects";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
animate: _animate,
|
||||
initial: _initial,
|
||||
transition: _transition,
|
||||
variants: _variants,
|
||||
viewport: _viewport,
|
||||
whileHover: _whileHover,
|
||||
whileInView: _whileInView,
|
||||
whileTap: _whileTap,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { motion };
|
||||
});
|
||||
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => true,
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, title }: { children: ReactNode; title: string }) => (
|
||||
<section>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
const [baseProject] = getFeaturedProjects(null);
|
||||
const projects: PortfolioProjectView[] = [
|
||||
{
|
||||
...baseProject,
|
||||
id: "available-demo",
|
||||
title: "Demo disponível",
|
||||
description: "Descrição disponível",
|
||||
technologies: ["Stack disponível"],
|
||||
status: "Disponível",
|
||||
demoUrl: "https://available.example",
|
||||
githubUrl: "https://github.com/example/available",
|
||||
demoAvailability: "available",
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
...baseProject,
|
||||
id: "unavailable-demo",
|
||||
title: "Demo indisponível",
|
||||
description: "Descrição indisponível",
|
||||
technologies: ["Stack indisponível"],
|
||||
status: "Indisponível",
|
||||
demoUrl: "https://unavailable.example",
|
||||
githubUrl: "https://github.com/example/unavailable",
|
||||
demoAvailability: "unavailable",
|
||||
demoCheckedAt: "2026-09-04T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
...baseProject,
|
||||
id: "unknown-demo",
|
||||
title: "Demo desconhecida",
|
||||
description: "Descrição desconhecida",
|
||||
technologies: ["Stack desconhecida"],
|
||||
status: "Não verificada",
|
||||
demoUrl: "https://unknown.example",
|
||||
githubUrl: "https://github.com/example/unknown",
|
||||
demoAvailability: "unknown",
|
||||
demoCheckedAt: null,
|
||||
},
|
||||
{
|
||||
...baseProject,
|
||||
id: "no-demo",
|
||||
title: "Projeto sem demo",
|
||||
description: "Descrição sem demo",
|
||||
technologies: ["Stack sem demo"],
|
||||
status: "Sem demo",
|
||||
demoUrl: null,
|
||||
githubUrl: "https://github.com/example/no-demo",
|
||||
demoAvailability: "none",
|
||||
demoCheckedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe("Projects", () => {
|
||||
it("renders demo actions according to availability", () => {
|
||||
render(<Projects projects={projects} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Demo disponível" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://available.example",
|
||||
);
|
||||
|
||||
const unavailable = screen.getByText(
|
||||
"Demo indisponível na última verificação",
|
||||
);
|
||||
expect(unavailable).toHaveAttribute("aria-disabled", "true");
|
||||
expect(unavailable).not.toHaveAttribute("href");
|
||||
expect(unavailable.closest("a")).toBeNull();
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: /Abrir projeto.*Disponibilidade não verificada/i,
|
||||
}),
|
||||
).toHaveAttribute("href", "https://unknown.example");
|
||||
|
||||
const noDemoCard = screen
|
||||
.getByRole("heading", { name: "Projeto sem demo" })
|
||||
.closest("article");
|
||||
expect(noDemoCard).not.toBeNull();
|
||||
expect(
|
||||
within(noDemoCard as HTMLElement).queryByRole("link", {
|
||||
name: /Abrir projeto/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps project evidence visible when the demo is unavailable", () => {
|
||||
render(<Projects projects={projects} />);
|
||||
|
||||
const card = screen
|
||||
.getByRole("heading", { name: "Demo indisponível" })
|
||||
.closest("article");
|
||||
expect(card).not.toBeNull();
|
||||
const unavailableCard = within(card as HTMLElement);
|
||||
|
||||
expect(unavailableCard.getByText("Descrição indisponível")).toBeVisible();
|
||||
expect(unavailableCard.getByText("Stack indisponível")).toBeVisible();
|
||||
expect(unavailableCard.getByText("Indisponível")).toBeVisible();
|
||||
expect(
|
||||
unavailableCard.getByRole("link", { name: "Ver codigo" }),
|
||||
).toHaveAttribute("href", "https://github.com/example/unavailable");
|
||||
expect(
|
||||
unavailableCard.queryByRole("link", { name: /Abrir projeto/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+26
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Section } from "./Section";
|
||||
@@ -10,7 +10,7 @@ import { Section } from "./Section";
|
||||
export default function Projects({
|
||||
projects,
|
||||
}: {
|
||||
projects: PortfolioProject[];
|
||||
projects: PortfolioProjectView[];
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
@@ -114,17 +114,30 @@ export default function Projects({
|
||||
Ver codigo
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir projeto
|
||||
</MagneticButton>
|
||||
{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>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface RouteTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RouteTransition({ children }: Readonly<RouteTransitionProps>) {
|
||||
const pathname = usePathname();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -20 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0.2 }
|
||||
: {
|
||||
opacity: { duration: 0.3, ease: [0.16, 1, 0.3, 1] },
|
||||
y: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { TopProgressBar } from "./animations/TopProgressBar";
|
||||
|
||||
interface SectionLoadingProps {
|
||||
@@ -14,6 +15,11 @@ export function SectionLoading({
|
||||
skeletons = 2,
|
||||
}: Readonly<SectionLoadingProps>) {
|
||||
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||
const skeletonId = useId();
|
||||
const skeletonKeys = Array.from(
|
||||
{ length: skeletons },
|
||||
(_, index) => `${skeletonId}-${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={sectionClass}>
|
||||
@@ -21,8 +27,8 @@ export function SectionLoading({
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-loading">{message}</div>
|
||||
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
|
||||
{Array.from({ length: skeletons }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
|
||||
{skeletonKeys.map((skeletonKey) => (
|
||||
<div key={skeletonKey} className="ds-card ds-skeleton-card">
|
||||
<div className="ds-skeleton ds-skeleton-kicker" />
|
||||
<div className="ds-skeleton ds-skeleton-title" />
|
||||
<div className="ds-skeleton ds-skeleton-line" />
|
||||
|
||||
@@ -128,11 +128,17 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel="right">
|
||||
<p
|
||||
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
|
||||
data-reel="right"
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel-stagger="fade">
|
||||
<ul
|
||||
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
|
||||
data-reel-stagger="fade"
|
||||
>
|
||||
{tier.deliverables.map((deliverable) => (
|
||||
<li key={deliverable} className="pl-sp-1">
|
||||
{deliverable}
|
||||
@@ -221,7 +227,10 @@ export function WorkModalities() {
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3" data-reel-stagger="left">
|
||||
<div
|
||||
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"
|
||||
data-reel-stagger="left"
|
||||
>
|
||||
{serviceHubLinks.map((service) => (
|
||||
<Link
|
||||
key={service.href}
|
||||
|
||||
@@ -36,7 +36,17 @@ export function TextDiffuseReveal({
|
||||
return () => media.removeEventListener("change", updateStagger);
|
||||
}, []);
|
||||
|
||||
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
|
||||
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,
|
||||
@@ -58,75 +68,66 @@ export function TextDiffuseReveal({
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-label={text}
|
||||
style={{
|
||||
willChange: hasActiveWillChange
|
||||
? "filter, opacity, transform"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{tokens.map((token, tokenIndex) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{tokens.map(({ key, value: token }) => {
|
||||
if (token === "\n") {
|
||||
return <br key={key} />;
|
||||
}
|
||||
|
||||
if (token === "\n") {
|
||||
return <br key={`line-${tokenIndex}`} />;
|
||||
}
|
||||
if (/^\s+$/.test(token)) {
|
||||
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(token)) {
|
||||
return (
|
||||
<span key={`space-${tokenIndex}`} aria-hidden="true">
|
||||
{token.replace(/ /g, "\u00A0")}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`word-${tokenIndex}`}
|
||||
className="inline-block whitespace-nowrap"
|
||||
>
|
||||
{Array.from(token).map((char, charIndex) => {
|
||||
const currentIndex = animatedIndex;
|
||||
animatedIndex += 1;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={`char-${tokenIndex}-${charIndex}-${char}`}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
animate={
|
||||
isInView
|
||||
? { 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,4 +1,3 @@
|
||||
import React from "react";
|
||||
export default function PointCloudScene() {
|
||||
return <div data-testid="point-cloud-mock"></div>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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", () => {
|
||||
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,5 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import CinematicHome from "./CinematicHome";
|
||||
|
||||
jest.mock("next/dynamic", () => {
|
||||
@@ -19,41 +18,44 @@ jest.mock("next/dynamic", () => {
|
||||
return () => {
|
||||
const label = labels[index++] ?? "DynamicSection";
|
||||
return function DynamicMock() {
|
||||
return <div data-testid={`dynamic-${label}`}>{label}</div>;
|
||||
return <section data-testid={`home-section-${label}`}>{label}</section>;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../Hero", () => () => <div data-testid="hero" />);
|
||||
jest.mock("../Projects", () => () => <div data-testid="projects" />);
|
||||
jest.mock("../story/ReelDirector", () => () => (
|
||||
<div data-testid="reel-director" />
|
||||
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />);
|
||||
jest.mock("../Projects", () => () => (
|
||||
<section data-testid="home-section-Projects" />
|
||||
));
|
||||
jest.mock("../story/NotebookStory", () => () => (
|
||||
<section data-testid="notebook-story" />
|
||||
<section data-testid="home-section-NotebookStory" />
|
||||
));
|
||||
jest.mock(
|
||||
"./CinematicSection",
|
||||
() =>
|
||||
({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
);
|
||||
|
||||
describe("CinematicHome", () => {
|
||||
it("uses one notebook story and keeps About after it", () => {
|
||||
render(
|
||||
it("renders each primary section directly in the published order", () => {
|
||||
const { container } = render(
|
||||
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />,
|
||||
);
|
||||
const main = container.querySelector("main");
|
||||
const sectionNames = [
|
||||
"Hero",
|
||||
"NotebookStory",
|
||||
"About",
|
||||
"Skills",
|
||||
"Experience",
|
||||
"Projects",
|
||||
"LighthouseMetrics",
|
||||
"WorkModalities",
|
||||
"Contact",
|
||||
];
|
||||
const sectionIds = sectionNames.map((name) => `home-section-${name}`);
|
||||
const directSections = Array.from(main?.children ?? []).filter((element) =>
|
||||
sectionIds.includes(element.getAttribute("data-testid") ?? ""),
|
||||
);
|
||||
|
||||
const story = screen.getByTestId("notebook-story");
|
||||
const about = screen.getByTestId("dynamic-About");
|
||||
|
||||
expect(story).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("print-reel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("scroll-story")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("home-section-NotebookStory")).toHaveLength(1);
|
||||
expect(
|
||||
Boolean(
|
||||
story.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
).toBe(true);
|
||||
directSections.map((section) => section.getAttribute("data-testid")),
|
||||
).toEqual(sectionIds);
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,13 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
PortfolioProject,
|
||||
PortfolioProjectView,
|
||||
PortfolioSkillCategory,
|
||||
} from "@/lib/portfolio-data";
|
||||
import Hero from "../Hero";
|
||||
import Projects from "../Projects";
|
||||
import { SectionLoading } from "../SectionLoading";
|
||||
import NotebookStory from "../story/NotebookStory";
|
||||
import ReelDirector from "../story/ReelDirector";
|
||||
import CinematicSection from "./CinematicSection";
|
||||
|
||||
const About = dynamic(() => import("../About"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
@@ -46,67 +44,52 @@ export default function CinematicHome({
|
||||
experiences,
|
||||
skillCategories,
|
||||
}: {
|
||||
projects: PortfolioProject[];
|
||||
projects: PortfolioProjectView[];
|
||||
experiences: PortfolioExperience[];
|
||||
skillCategories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
<main className="ds-main">
|
||||
<ReelDirector />
|
||||
<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" baseVelocity={-1.5} />
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
|
||||
</div>
|
||||
|
||||
<NotebookStory projects={projects} />
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<About />
|
||||
</CinematicSection>
|
||||
<About />
|
||||
|
||||
<SectionDivider variant="waveform" />
|
||||
|
||||
<CinematicSection effect="clip-reveal">
|
||||
<Skills categories={skillCategories} />
|
||||
</CinematicSection>
|
||||
<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"
|
||||
baseVelocity={1}
|
||||
direction="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CinematicSection effect="fade-scale">
|
||||
<Experience experiences={experiences} />
|
||||
</CinematicSection>
|
||||
<Experience experiences={experiences} />
|
||||
|
||||
<SectionDivider variant="gradient" />
|
||||
|
||||
<CinematicSection effect="rotate-in">
|
||||
<Projects projects={projects} />
|
||||
</CinematicSection>
|
||||
<Projects projects={projects} />
|
||||
|
||||
<CinematicSection effect="clip-reveal">
|
||||
<LighthouseMetrics />
|
||||
</CinematicSection>
|
||||
<LighthouseMetrics />
|
||||
|
||||
<SectionDivider variant="pulse" />
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<WorkModalities />
|
||||
</CinematicSection>
|
||||
<WorkModalities />
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
|
||||
</div>
|
||||
|
||||
<CinematicSection effect="fade-scale">
|
||||
<Contact />
|
||||
</CinematicSection>
|
||||
<Contact />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import PageIntro from "./PageIntro";
|
||||
|
||||
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||
ssr: false,
|
||||
@@ -16,10 +14,8 @@ export default function CinematicOverlays() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageIntro />
|
||||
{isHome ? (
|
||||
<>
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
</>
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function CinematicSection({
|
||||
children,
|
||||
className = "",
|
||||
effect = "fade-scale",
|
||||
id,
|
||||
}: CinematicSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Check reduced motion preference
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
switch (effect) {
|
||||
case "fade-scale": {
|
||||
gsap.set(el, {
|
||||
opacity: 0,
|
||||
scale: 0.92,
|
||||
transformOrigin: "center center",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 85%",
|
||||
end: "top 20%",
|
||||
scrub: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "clip-reveal": {
|
||||
gsap.set(el, {
|
||||
clipPath: "inset(15% 5% 15% 5%)",
|
||||
opacity: 0.3,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
end: "top 15%",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
clipPath: "inset(0% 0% 0% 0%)",
|
||||
opacity: 1,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "parallax-depth": {
|
||||
gsap.set(el, {
|
||||
y: 80,
|
||||
opacity: 0,
|
||||
rotateX: 4,
|
||||
transformPerspective: 1200,
|
||||
transformOrigin: "center bottom",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 90%",
|
||||
end: "top 30%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
rotateX: 0,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "rotate-in": {
|
||||
gsap.set(el, {
|
||||
opacity: 0,
|
||||
rotateY: -5,
|
||||
x: -60,
|
||||
transformPerspective: 1000,
|
||||
transformOrigin: "left center",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
end: "top 25%",
|
||||
scrub: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
opacity: 1,
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, [effect]);
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className={className} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
tag?: "h1" | "h2" | "h3" | "p" | "span";
|
||||
delay?: number;
|
||||
splitBy?: "chars" | "words" | "lines";
|
||||
}
|
||||
|
||||
export default function CinematicText({
|
||||
text,
|
||||
className = "",
|
||||
tag: Tag = "h2",
|
||||
delay = 0,
|
||||
splitBy = "chars",
|
||||
}: CinematicTextProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const spans = el.querySelectorAll("[data-char]");
|
||||
if (spans.length === 0) return;
|
||||
|
||||
gsap.set(spans, {
|
||||
opacity: 0,
|
||||
y: 60,
|
||||
rotateX: -90,
|
||||
transformOrigin: "center bottom",
|
||||
transformPerspective: 600,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(spans, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
duration: 0.8,
|
||||
ease: "back.out(1.2)",
|
||||
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
|
||||
delay,
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, [text, delay, splitBy]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (splitBy === "chars") {
|
||||
return text.split("").map((char, i) => (
|
||||
<span
|
||||
key={`${char}-${i}`}
|
||||
data-char
|
||||
className="inline-block will-change-transform"
|
||||
style={{ whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
if (splitBy === "words") {
|
||||
return text.split(" ").map((word, i) => (
|
||||
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
|
||||
<span data-char className="inline-block will-change-transform">
|
||||
{word}
|
||||
</span>
|
||||
{i < text.split(" ").length - 1 && "\u00A0"}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
// lines
|
||||
return text.split("\n").map((line, i) => (
|
||||
<span key={`${line}-${i}`} className="block overflow-hidden">
|
||||
<span data-char className="block will-change-transform">
|
||||
{line}
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="overflow-hidden">
|
||||
<Tag className={className}>{renderContent()}</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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", 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: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ControlRoomGlobe({
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
}) {
|
||||
const [width, setWidth] = useState(0);
|
||||
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);
|
||||
@@ -55,7 +55,7 @@ export function ControlRoomGlobe({
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerYOrigin = useRef<number | null>(null);
|
||||
const isVisibleRef = useRef(true);
|
||||
const intersectsRef = useRef(false);
|
||||
|
||||
const r = useMotionValue(0);
|
||||
const rs = useSpring(r, {
|
||||
@@ -88,57 +88,44 @@ export function ControlRoomGlobe({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
setWidth(canvasRef.current.offsetWidth);
|
||||
}
|
||||
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();
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisibleRef.current = entry.isIntersecting;
|
||||
intersectsRef.current = entry.isIntersecting;
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
intersectionObserver.observe(canvas);
|
||||
|
||||
if (canvasRef.current) {
|
||||
observer.observe(canvasRef.current);
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
isVisibleRef.current = !document.hidden;
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
// 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: ReturnType<typeof createGlobe> | null = null;
|
||||
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: width * 2,
|
||||
height: width * 2,
|
||||
width: initialRenderWidth,
|
||||
height: initialRenderWidth,
|
||||
onRender: (state) => {
|
||||
if (!isVisibleRef.current || document.hidden) return;
|
||||
if (autoRotate && !pointerInteracting.current) {
|
||||
if (!autoRotate || !intersectsRef.current || document.hidden)
|
||||
return;
|
||||
if (!pointerInteracting.current) {
|
||||
phiRef.current += rotationSpeed;
|
||||
thetaPhaseRef.current += rotationSpeed * 0.9;
|
||||
}
|
||||
@@ -148,8 +135,9 @@ export function ControlRoomGlobe({
|
||||
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
||||
Math.sin(thetaPhaseRef.current) * 0.35 +
|
||||
thetaDragRef.current;
|
||||
state.width = width * 2;
|
||||
state.height = width * 2;
|
||||
const renderWidth = widthRef.current * dpr;
|
||||
state.width = renderWidth;
|
||||
state.height = renderWidth;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -161,28 +149,24 @@ export function ControlRoomGlobe({
|
||||
// Sem globo: fallback visível (gradiente do card permanece), página viva.
|
||||
canvas.style.opacity = "1";
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const revealTimer = window.setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(revealTimer);
|
||||
globe.destroy();
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [rs, config, autoRotate, rotationSpeed, width]);
|
||||
}, [rs, config, autoRotate, rotationSpeed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function CustomCursor() {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isClicking, setIsClicking] = useState(false);
|
||||
const [hoverText, setHoverText] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const cursorX = useMotionValue(0);
|
||||
const cursorY = useMotionValue(0);
|
||||
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
cursorX.set(e.clientX);
|
||||
cursorY.set(e.clientY);
|
||||
if (!isVisible) setIsVisible(true);
|
||||
});
|
||||
},
|
||||
[cursorX, cursorY, isVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only show custom cursor on desktop with fine pointer
|
||||
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
|
||||
if (!hasFinePointer) return;
|
||||
|
||||
const handleDown = () => setIsClicking(true);
|
||||
const handleUp = () => setIsClicking(false);
|
||||
const handleLeave = () => setIsVisible(false);
|
||||
const handleEnter = () => setIsVisible(true);
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mousedown", handleDown);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
document.addEventListener("mouseleave", handleLeave);
|
||||
document.addEventListener("mouseenter", handleEnter);
|
||||
|
||||
// Detect hoverable elements
|
||||
const observer = new MutationObserver(() => {
|
||||
attachHoverListeners();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
attachHoverListeners();
|
||||
|
||||
function attachHoverListeners() {
|
||||
const hoverables = document.querySelectorAll(
|
||||
'a, button, [role="button"], input, textarea, select, [data-cursor]',
|
||||
);
|
||||
for (const el of hoverables) {
|
||||
el.addEventListener("mouseenter", handleElementEnter as EventListener);
|
||||
el.addEventListener("mouseleave", handleElementLeave as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
function handleElementEnter(e: Event) {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
setIsHovering(true);
|
||||
const cursorData = el.getAttribute("data-cursor");
|
||||
if (cursorData) setHoverText(cursorData);
|
||||
}
|
||||
|
||||
function handleElementLeave() {
|
||||
setIsHovering(false);
|
||||
setHoverText("");
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousedown", handleDown);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.removeEventListener("mouseleave", handleLeave);
|
||||
document.removeEventListener("mouseenter", handleEnter);
|
||||
observer.disconnect();
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [handleMouseMove]);
|
||||
|
||||
// Don't render on touch devices
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!window.matchMedia("(pointer: fine)").matches
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
@media (pointer: fine) {
|
||||
* { cursor: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Dot cursor */}
|
||||
<motion.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
|
||||
style={{
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
}}
|
||||
animate={{
|
||||
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-[#ccff00]"
|
||||
style={{ width: 8, height: 8 }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Ring cursor */}
|
||||
<motion.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
|
||||
style={{
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
}}
|
||||
animate={{
|
||||
width: isHovering ? 80 : 40,
|
||||
height: isHovering ? 80 : 40,
|
||||
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
|
||||
borderWidth: isHovering ? 2 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
|
||||
style={{
|
||||
borderStyle: "solid",
|
||||
borderColor: "#ccff00",
|
||||
borderWidth: "inherit",
|
||||
}}
|
||||
>
|
||||
{hoverText && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
|
||||
>
|
||||
{hoverText}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function FilmGrain() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (reducedMotion || coarsePointer) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animId: number;
|
||||
let lastFrame = 0;
|
||||
const fps = 24; // Cinematic film fps
|
||||
const frameInterval = 1000 / fps;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth / 2;
|
||||
canvas.height = window.innerHeight / 2;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const render = (timestamp: number) => {
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
const delta = timestamp - lastFrame;
|
||||
if (delta < frameInterval) return;
|
||||
lastFrame = timestamp - (delta % frameInterval);
|
||||
|
||||
const { width, height } = canvas;
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const noise = Math.random() * 20;
|
||||
data[i] = noise;
|
||||
data[i + 1] = noise;
|
||||
data[i + 2] = noise;
|
||||
data[i + 3] = 12; // Very subtle
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
);
|
||||
return <div aria-hidden="true" className="film-grain" />;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Float,
|
||||
MeshDistortMaterial,
|
||||
MeshTransmissionMaterial,
|
||||
} from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
function GlassSphere({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
|
||||
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
|
||||
});
|
||||
|
||||
return (
|
||||
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||
<mesh ref={meshRef} position={position} scale={scale}>
|
||||
<sphereGeometry args={[1, 32, 32]} />
|
||||
<MeshDistortMaterial
|
||||
color="#ccff00"
|
||||
transparent
|
||||
opacity={0.08}
|
||||
distort={0.4}
|
||||
speed={2}
|
||||
wireframe
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
);
|
||||
}
|
||||
|
||||
function WireframeRing({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
meshRef.current.rotation.x = t * 0.1;
|
||||
meshRef.current.rotation.y = t * 0.15;
|
||||
});
|
||||
|
||||
return (
|
||||
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
|
||||
<mesh ref={meshRef} position={position} scale={scale}>
|
||||
<torusGeometry args={[1, 0.02, 16, 100]} />
|
||||
<meshStandardMaterial
|
||||
color="#ccff00"
|
||||
transparent
|
||||
opacity={0.15}
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
);
|
||||
}
|
||||
|
||||
function DataStream({ count = 50 }: { count?: number }) {
|
||||
const pointsRef = useRef<THREE.Points>(null);
|
||||
|
||||
const positions = useMemo(() => {
|
||||
const pos = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
// Create a spiral/helix pattern
|
||||
const angle = (i / count) * Math.PI * 8;
|
||||
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
|
||||
pos[i3] = Math.cos(angle) * radius;
|
||||
pos[i3 + 1] = (i / count - 0.5) * 12;
|
||||
pos[i3 + 2] = Math.sin(angle) * radius;
|
||||
}
|
||||
return pos;
|
||||
}, [count]);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!pointsRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
pointsRef.current.rotation.y = t * 0.05;
|
||||
|
||||
const posAttr = pointsRef.current.geometry.getAttribute(
|
||||
"position",
|
||||
) as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
arr[i3 + 1] += 0.02;
|
||||
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={pointsRef}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions, 3]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
color="#ccff00"
|
||||
size={0.04}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
sizeAttenuation
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
interface FloatingElementsProps {
|
||||
variant?: "sphere" | "ring" | "data" | "combined";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FloatingElements({
|
||||
variant = "combined",
|
||||
className = "",
|
||||
}: FloatingElementsProps) {
|
||||
return (
|
||||
<div className={`pointer-events-none absolute inset-0 ${className}`}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 6], fov: 50 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{ background: "transparent" }}
|
||||
>
|
||||
<ambientLight intensity={0.2} />
|
||||
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
|
||||
|
||||
{(variant === "sphere" || variant === "combined") && (
|
||||
<>
|
||||
<GlassSphere position={[3, 1, -2]} scale={0.8} />
|
||||
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(variant === "ring" || variant === "combined") && (
|
||||
<>
|
||||
<WireframeRing position={[0, 0, -2]} scale={1.5} />
|
||||
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(variant === "data" || variant === "combined") && (
|
||||
<DataStream count={60} />
|
||||
)}
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,6 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
type HeroSceneVariant =
|
||||
| "command"
|
||||
| "launch"
|
||||
| "rescue"
|
||||
| "signal"
|
||||
| "diagnostic"
|
||||
| "portrait";
|
||||
|
||||
function GridFloor() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
@@ -104,13 +96,10 @@ function CameraRig() {
|
||||
}
|
||||
|
||||
export default function HeroScene({
|
||||
variant,
|
||||
isVisible = true,
|
||||
}: {
|
||||
variant?: HeroSceneVariant;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
void variant;
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
export default function HomeButton() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!isHome && (
|
||||
<motion.div
|
||||
className="fixed left-6 top-6 z-[9000]"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||
>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/"
|
||||
strength={0.3}
|
||||
aria-label="Voltar para o início"
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.72)] px-4 py-2.5 text-[11px] font-mono uppercase tracking-[0.1em] text-[rgba(244,242,237,0.76)] backdrop-blur-[16px] transition-all duration-300 hover:border-[rgba(204,255,0,0.5)] hover:text-[var(--slash)]"
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Arrow left icon */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="relative z-10 -translate-x-0 transition-transform duration-300 group-hover:-translate-x-0.5"
|
||||
>
|
||||
<path d="M7.5 1.5 L2.5 6 L7.5 10.5" />
|
||||
</svg>
|
||||
|
||||
<span className="relative z-10">Home</span>
|
||||
|
||||
{/* Animated neon dot */}
|
||||
<motion.span
|
||||
className="relative z-10 ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--slash)]"
|
||||
animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface HorizontalScrollProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function HorizontalScroll({
|
||||
children,
|
||||
className = "",
|
||||
}: HorizontalScrollProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!container || !track) return;
|
||||
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
// Disable on mobile for better UX
|
||||
if (window.innerWidth < 768) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const totalWidth = track.scrollWidth;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const tween = gsap.to(track, {
|
||||
x: -(totalWidth - viewportWidth),
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
end: () => `+=${totalWidth - viewportWidth}`,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Animate individual items as they come into view
|
||||
const items = track.querySelectorAll("[data-scroll-item]");
|
||||
for (const item of items) {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{
|
||||
opacity: 0.3,
|
||||
scale: 0.9,
|
||||
rotateY: -8,
|
||||
transformPerspective: 1000,
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateY: 0,
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
containerAnimation: tween,
|
||||
start: "left 80%",
|
||||
end: "left 30%",
|
||||
scrub: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
ScrollTrigger.getAll().forEach((st) => {
|
||||
if (
|
||||
st.trigger === container ||
|
||||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
|
||||
tween
|
||||
) {
|
||||
st.kill();
|
||||
}
|
||||
});
|
||||
tween.kill();
|
||||
};
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex items-stretch gap-sp-6 will-change-transform"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
|
||||
target?: string;
|
||||
rel?: string;
|
||||
download?: boolean | string;
|
||||
"data-cursor"?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
wrapperClassName?: string;
|
||||
"aria-label"?: string;
|
||||
@@ -29,7 +28,6 @@ export default function MagneticButton({
|
||||
target,
|
||||
rel,
|
||||
download,
|
||||
"data-cursor": dataCursor,
|
||||
type,
|
||||
wrapperClassName = "",
|
||||
"aria-label": ariaLabel,
|
||||
@@ -84,7 +82,6 @@ export default function MagneticButton({
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
@@ -102,7 +99,6 @@ export default function MagneticButton({
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import PageIntro from "./PageIntro";
|
||||
|
||||
jest.mock("react-dom", () => ({
|
||||
...jest.requireActual("react-dom"),
|
||||
createPortal: (children: ReactNode) => children,
|
||||
}));
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_target, tag: string) =>
|
||||
({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement(tag, props, children),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
AnimatePresence: ({ children }: { children: ReactNode }) => children,
|
||||
motion,
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
describe("PageIntro", () => {
|
||||
it("renders the compact signal loading state without a fake percentage", () => {
|
||||
render(<PageIntro />);
|
||||
|
||||
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
|
||||
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
expect(screen.queryByText("000")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays visible long enough to be perceived on a fast page load", () => {
|
||||
jest.useFakeTimers();
|
||||
render(<PageIntro />);
|
||||
|
||||
act(() => jest.advanceTimersByTime(900));
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
|
||||
act(() => jest.runAllTimers());
|
||||
expect(
|
||||
screen.queryByText("Preparando o portfólio"),
|
||||
).not.toBeInTheDocument();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
|
||||
|
||||
export default function PageIntro() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const timeout = window.setTimeout(
|
||||
() => setVisible(false),
|
||||
reduceMotion ? 150 : 1350,
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [reduceMotion]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={
|
||||
reduceMotion
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
clipPath: "inset(0 0 100% 0)",
|
||||
transition: { duration: 0.65, ease: EXIT_EASE },
|
||||
}
|
||||
}
|
||||
aria-live="polite"
|
||||
aria-label="Preparando o portfólio"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
|
||||
|
||||
<div className="relative m-auto w-[min(88vw,680px)]">
|
||||
<motion.p
|
||||
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
SINAL DE ENTRADA
|
||||
</motion.p>
|
||||
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
|
||||
<motion.p
|
||||
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.48,
|
||||
delay: 0.06,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="mt-sp-8 flex items-center gap-sp-4"
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.28, delay: 0.18 }}
|
||||
>
|
||||
<span className="relative block h-2 w-2" aria-hidden="true">
|
||||
<span className="absolute inset-0 bg-[var(--slash)]" />
|
||||
{!reduceMotion && (
|
||||
<motion.span
|
||||
className="absolute inset-0 bg-[var(--slash)]"
|
||||
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
|
||||
Preparando o portfólio
|
||||
</p>
|
||||
<span
|
||||
className="h-px flex-1 bg-[rgba(244,242,237,.22)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
|
||||
<span>Engenharia de software</span>
|
||||
<span>Garuva · Joinville · Curitiba</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,100 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useMotionValue,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
baseVelocity?: number;
|
||||
direction: "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function wrap(min: number, max: number, v: number) {
|
||||
const rangeSize = max - min;
|
||||
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
|
||||
}
|
||||
const GROUPS = [0, 1] as const;
|
||||
|
||||
export default function ScrollVelocityText({
|
||||
text,
|
||||
baseVelocity = 2,
|
||||
direction,
|
||||
className = "",
|
||||
}: ScrollVelocityTextProps) {
|
||||
const baseX = useMotionValue(0);
|
||||
const { scrollY } = useScroll();
|
||||
const scrollVelocity = useMotionValue(0);
|
||||
const smoothVelocity = useSpring(scrollVelocity, {
|
||||
damping: 50,
|
||||
stiffness: 300,
|
||||
});
|
||||
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
|
||||
clamp: false,
|
||||
});
|
||||
|
||||
const [repetitions, setRepetitions] = useState(4);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trackId = useId();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePreference = (event: MediaQueryListEvent) => {
|
||||
setShouldReduceMotion(event.matches);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
media.addEventListener("change", updatePreference);
|
||||
return () => media.removeEventListener("change", updatePreference);
|
||||
}, []);
|
||||
|
||||
// Track scroll velocity
|
||||
const prevScrollY = useRef(0);
|
||||
useEffect(() => {
|
||||
return scrollY.on("change", (latest) => {
|
||||
const delta = latest - prevScrollY.current;
|
||||
prevScrollY.current = latest;
|
||||
scrollVelocity.set(Math.abs(delta));
|
||||
});
|
||||
}, [scrollY, scrollVelocity]);
|
||||
|
||||
const directionFactor = useRef(1);
|
||||
|
||||
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
|
||||
|
||||
useAnimationFrame((_, delta) => {
|
||||
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
|
||||
|
||||
const vf = velocityFactor.get();
|
||||
if (vf < 0) {
|
||||
directionFactor.current = -1;
|
||||
} else if (vf > 0) {
|
||||
directionFactor.current = 1;
|
||||
}
|
||||
|
||||
moveBy += directionFactor.current * moveBy * vf;
|
||||
baseX.set(baseX.get() + moveBy);
|
||||
});
|
||||
const animationPaused = shouldReduceMotion || isPaused;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`overflow-hidden whitespace-nowrap ${className}`}
|
||||
>
|
||||
<motion.div className="inline-flex" style={{ x }}>
|
||||
{[...Array(repetitions)].map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
|
||||
>
|
||||
{text}
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">
|
||||
•
|
||||
<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>
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shouldReduceMotion ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-velocity-control"
|
||||
aria-controls={trackId}
|
||||
aria-pressed={isPaused}
|
||||
onClick={() => setIsPaused((paused) => !paused)}
|
||||
>
|
||||
{isPaused ? "Retomar animação" : "Pausar animação"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "waveform") return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animId: number;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = 60;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const render = () => {
|
||||
animId = requestAnimationFrame(render);
|
||||
const { width, height } = canvas;
|
||||
const t = performance.now() * 0.001;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw multiple waveforms
|
||||
for (let wave = 0; wave < 3; wave++) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = `rgba(204, 255, 0, ${0.1 - wave * 0.03})`;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const frequency = 0.005 + wave * 0.002;
|
||||
const amplitude = 8 + wave * 4;
|
||||
const speed = t * (0.5 + wave * 0.2);
|
||||
const y =
|
||||
height / 2 +
|
||||
Math.sin(x * frequency + speed) * amplitude +
|
||||
Math.sin(x * frequency * 2.3 + speed * 1.5) * (amplitude * 0.4);
|
||||
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [variant]);
|
||||
const rootClassName =
|
||||
`section-divider section-divider-${variant} ${className}`.trim();
|
||||
|
||||
if (variant === "gradient") {
|
||||
return (
|
||||
<div className={`relative h-px w-full overflow-hidden ${className}`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-30" />
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-60"
|
||||
style={{
|
||||
animation: "shimmerDivider 3s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pulse") {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-8 ${className}`}
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-1 w-1 rounded-full bg-[var(--slash)]"
|
||||
style={{
|
||||
animation: `pulseDot 2s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
{PULSE_DOTS.map((dot) => (
|
||||
<span key={dot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// waveform
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full opacity-60"
|
||||
style={{ height: 60 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
|
||||
<title>Divisor decorativo</title>
|
||||
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
|
||||
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
|
||||
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const frame: NotebookBrowserProps["frame"] = {
|
||||
cursorVisible: true,
|
||||
cursorX: 0.4,
|
||||
@@ -113,15 +113,65 @@ describe("NotebookBrowser", () => {
|
||||
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
|
||||
});
|
||||
|
||||
it("exposes a real deployment link with safe new-tab attributes", () => {
|
||||
renderBrowser();
|
||||
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/i });
|
||||
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("keeps the animated cursor decorative and never embeds an iframe", () => {
|
||||
const { container } = renderBrowser({
|
||||
frame: { ...frame, cursorClicking: true },
|
||||
|
||||
@@ -176,13 +176,26 @@ export default function NotebookBrowser({
|
||||
<li key={technology}>{technology}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={selectedProject.demoUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Abrir projeto <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
{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">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
|
||||
describe("NotebookFallback", () => {
|
||||
it("keeps project content and controls in normal document flow", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
getNotebookLidRotation,
|
||||
type NotebookStage3DProps,
|
||||
@@ -30,7 +30,7 @@ jest.mock("@react-three/drei", () => ({
|
||||
RoundedBox: () => <div data-testid="mock-roundedbox" />,
|
||||
}));
|
||||
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const projects = selectNotebookProjects(getFeaturedProjects(null));
|
||||
const props: NotebookStage3DProps = {
|
||||
activeIndex: 0,
|
||||
isVisible: true,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStory, {
|
||||
canUseNotebook3D,
|
||||
getNotebookRunwayVh,
|
||||
getNotebookSeekProgress,
|
||||
} 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() {
|
||||
return <div data-testid="notebook-stage" />;
|
||||
return function DynamicNotebookStage(props: { isVisible: boolean }) {
|
||||
return mockNotebookStage(props);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -26,7 +30,23 @@ jest.mock("gsap", () => ({
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
const projects = portfolio.projects;
|
||||
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,
|
||||
@@ -62,6 +82,26 @@ describe("NotebookStory", () => {
|
||||
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(() => {
|
||||
@@ -99,6 +139,43 @@ describe("NotebookStory", () => {
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toHaveAttribute("data-notebook-runway-vh", "520");
|
||||
});
|
||||
|
||||
it("stays paused when the tab returns while the section is offscreen", async () => {
|
||||
const addEventListener = jest.spyOn(document, "addEventListener");
|
||||
const removeEventListener = jest.spyOn(document, "removeEventListener");
|
||||
const { unmount } = 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");
|
||||
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(
|
||||
([type]) => type === "visibilitychange",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
unmount();
|
||||
expect(
|
||||
removeEventListener.mock.calls.filter(
|
||||
([type]) => type === "visibilitychange",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
import { getNotebookFrame } from "./notebookTimeline";
|
||||
@@ -18,7 +18,7 @@ const BROWSE_END = 0.78;
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProject[];
|
||||
projects: PortfolioProjectView[];
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
@@ -64,11 +64,17 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
const progressRef = useRef(0);
|
||||
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [sectionIntersects, setSectionIntersects] = useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = useState(
|
||||
() => typeof document === "undefined" || !document.hidden,
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
const updateEngine = () => {
|
||||
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
|
||||
const nextEngine =
|
||||
notebookProjects.length > 0 && canUseNotebook3D(window);
|
||||
if (!nextEngine) setSectionIntersects(false);
|
||||
setEngine(nextEngine);
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
@@ -91,6 +97,15 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
};
|
||||
}, [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 || !("IntersectionObserver" in window)) {
|
||||
@@ -98,7 +113,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsVisible(entry?.isIntersecting ?? false),
|
||||
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(section);
|
||||
@@ -246,7 +261,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
</div>
|
||||
<NotebookStage3D
|
||||
activeIndex={activeIndex}
|
||||
isVisible={isVisible}
|
||||
isVisible={sectionIntersects && isPageVisible}
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
|
||||
*
|
||||
* Contrato (declarativo, via atributos data-*):
|
||||
* [data-reel="left|right|top|bottom|zoom|fade"]
|
||||
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
|
||||
* [data-reel-stagger="left|right|top|zoom|fade|bottom"]
|
||||
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
|
||||
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
|
||||
* data-reel-distance="64" (opcional: px de deslocamento)
|
||||
*
|
||||
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
|
||||
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
|
||||
*
|
||||
* Anti-hidratação: elementos inicializados vivem num WeakSet (nunca um
|
||||
* atributo data-reel-init no DOM) e o primeiro scan roda depois que a
|
||||
* hidratação assentou (double-rAF + debounce) — MutationObserver dispara em
|
||||
* microtask e pegaria nós ainda em hidratação, produzindo mismatch de
|
||||
* atributos no overlay do Next.
|
||||
*/
|
||||
|
||||
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
|
||||
|
||||
const DISTANCE = 64;
|
||||
|
||||
function fromVars(dir: Direction, dist: number) {
|
||||
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
|
||||
if (dir === "left") return { ...base, x: -dist };
|
||||
if (dir === "right") return { ...base, x: dist };
|
||||
if (dir === "top") return { ...base, y: -dist * 0.75 };
|
||||
if (dir === "bottom") return { ...base, y: dist };
|
||||
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
|
||||
return { ...base, scale: 1.02 };
|
||||
}
|
||||
|
||||
const TO_VARS = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
ease: "none",
|
||||
};
|
||||
|
||||
export default function ReelDirector() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||
return undefined;
|
||||
|
||||
let teardown = () => {};
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
let timer = 0;
|
||||
|
||||
const boot = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const done = new WeakSet<Element>();
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scan = () => {
|
||||
if (cancelled) return;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel]",
|
||||
)) {
|
||||
if (done.has(el)) continue;
|
||||
done.add(el);
|
||||
const dir = (el.dataset.reel || "bottom") as Direction;
|
||||
const dist = Number(el.dataset.reelDistance || DISTANCE);
|
||||
gsap.fromTo(el, fromVars(dir, dist), {
|
||||
...TO_VARS,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: el.dataset.reelStart || "top 88%",
|
||||
end: el.dataset.reelEnd || "top 26%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const container of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel-stagger]",
|
||||
)) {
|
||||
if (done.has(container)) continue;
|
||||
done.add(container);
|
||||
const dir = (container.dataset.reelStagger ||
|
||||
"bottom") as Direction;
|
||||
const dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
if (children.length === 0) continue;
|
||||
gsap.fromTo(children, fromVars(dir, dist), {
|
||||
...TO_VARS,
|
||||
stagger: { each: 0.09 },
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: container.dataset.reelStart || "top 85%",
|
||||
end: container.dataset.reelEnd || "top 25%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// debounce: nós de seções dinâmicas (next/dynamic) chegam depois
|
||||
let pending = 0;
|
||||
const scheduleScan = () => {
|
||||
window.clearTimeout(pending);
|
||||
pending = window.setTimeout(scan, 120);
|
||||
};
|
||||
|
||||
// espera a hidratação assentar antes de tocar o DOM
|
||||
raf1 = window.requestAnimationFrame(() => {
|
||||
raf2 = window.requestAnimationFrame(() => {
|
||||
timer = window.setTimeout(scan, 250);
|
||||
});
|
||||
});
|
||||
|
||||
const observer = new MutationObserver(scheduleScan);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
teardown = () => {
|
||||
window.clearTimeout(pending);
|
||||
observer.disconnect();
|
||||
ctx.revert();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
void boot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(raf1);
|
||||
window.cancelAnimationFrame(raf2);
|
||||
window.clearTimeout(timer);
|
||||
teardown();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type PortfolioProject, portfolio } from "@/lib/portfolio-data";
|
||||
import {
|
||||
getFeaturedProjects,
|
||||
type PortfolioProjectView,
|
||||
} from "@/lib/portfolio-data";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
const projectsFixture = portfolio.projects as PortfolioProject[];
|
||||
const projectsFixture = getFeaturedProjects(null);
|
||||
|
||||
describe("selectNotebookProjects", () => {
|
||||
it("returns the four ordered projects with local screenshots", () => {
|
||||
@@ -31,26 +34,54 @@ describe("selectNotebookProjects", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a project without an approved demo URL", () => {
|
||||
const projectsWithoutDemoUrl = projectsFixture.map((project) =>
|
||||
project.id === "nutriscan" ? { ...project, demoUrl: null } : project,
|
||||
) as unknown as PortfolioProject[];
|
||||
const result = selectNotebookProjects(projectsWithoutDemoUrl);
|
||||
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("copies only the browser contract fields", () => {
|
||||
const [project] = selectNotebookProjects(projectsFixture);
|
||||
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(project).toEqual({
|
||||
id: "nutriscan",
|
||||
title: "NutriScan",
|
||||
subtitle: "Análise Nutricional Inteligente",
|
||||
demoUrl: "https://nutriscan.evandro.dev.br",
|
||||
technologies: expect.arrayContaining(["Next.js 14", "TypeScript"]),
|
||||
screenshotSrc: "/assets/prints/print-nutriscan.png",
|
||||
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",
|
||||
});
|
||||
expect(project).not.toHaveProperty("description");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
|
||||
export type NotebookProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
demoUrl: string;
|
||||
demoUrl: PortfolioProjectView["demoUrl"];
|
||||
demoAvailability: PortfolioProjectView["demoAvailability"];
|
||||
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
|
||||
technologies: string[];
|
||||
screenshotSrc: string;
|
||||
};
|
||||
@@ -27,19 +29,22 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
|
||||
};
|
||||
|
||||
function isApprovedDemoUrl(
|
||||
value: PortfolioProject["demoUrl"],
|
||||
value: PortfolioProjectView["demoUrl"],
|
||||
): value is string {
|
||||
return typeof value === "string" && /^https?:\/\//.test(value);
|
||||
}
|
||||
|
||||
export function selectNotebookProjects(
|
||||
projects: readonly PortfolioProject[],
|
||||
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 || !isApprovedDemoUrl(project.demoUrl)) {
|
||||
if (
|
||||
!project ||
|
||||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -49,6 +54,8 @@ export function selectNotebookProjects(
|
||||
title: project.title,
|
||||
subtitle: project.subtitle,
|
||||
demoUrl: project.demoUrl,
|
||||
demoAvailability: project.demoAvailability,
|
||||
demoCheckedAt: project.demoCheckedAt,
|
||||
technologies: [...project.technologies],
|
||||
screenshotSrc,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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());
|
||||
|
||||
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||
"Início",
|
||||
"Currículo",
|
||||
]);
|
||||
expect(breadcrumbLinks[0]).toHaveAttribute("href", "/");
|
||||
expect(breadcrumbLinks[1]).toHaveAttribute("href", "/curriculo");
|
||||
expect(breadcrumbLinks[1]).toHaveAttribute("aria-current", "page");
|
||||
|
||||
const hiringLink = screen.getByRole("link", { name: /oportunidade/i });
|
||||
const consultingLink = screen.getByRole("link", { name: /consultoria/i });
|
||||
expect(hiringLink).toHaveAttribute("href", "/#contact");
|
||||
expect(consultingLink).toHaveAttribute("href", "/servicos");
|
||||
expect(
|
||||
hiringLink.compareDocumentPosition(consultingLink) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
+37
-27
@@ -2,19 +2,20 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { buildBreadcrumbList, buildProfilePageJsonLd } from "@/lib/seo";
|
||||
import { siteName, siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/curriculo`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Currículo — Engenheiro e Desenvolvedor de Software",
|
||||
title: "Currículo de Evandro Fonseca Junior para Contratação",
|
||||
description:
|
||||
"Currículo de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba. Experiência em Next.js, TypeScript, Node.js, Rust, IoT e sistemas críticos.",
|
||||
"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: {
|
||||
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
|
||||
description:
|
||||
"Engenheiro da computação e desenvolvedor de software disponível para oportunidades CLT, PJ e projetos estratégicos no Norte de SC, Curitiba e remoto.",
|
||||
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
|
||||
url: pageUrl,
|
||||
siteName,
|
||||
type: "profile",
|
||||
@@ -24,25 +25,13 @@ export const metadata: Metadata = {
|
||||
export default function CurriculoPage() {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
dateModified: new Date().toISOString(),
|
||||
mainEntity: {
|
||||
"@type": "Person",
|
||||
"@id": `${siteUrl}/#person`,
|
||||
name: portfolio.personal.name,
|
||||
jobTitle:
|
||||
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
|
||||
description: portfolio.personal.summary,
|
||||
url: siteUrl,
|
||||
sameAs: [portfolio.personal.github, portfolio.personal.linkedin],
|
||||
homeLocation: {
|
||||
"@type": "Place",
|
||||
name: "Garuva, Santa Catarina, Brasil",
|
||||
},
|
||||
knowsAbout: portfolio.skills.technical.flatMap(
|
||||
(category) => category.skills,
|
||||
),
|
||||
},
|
||||
"@graph": [
|
||||
buildProfilePageJsonLd(),
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Currículo", pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,15 +39,33 @@ export default function CurriculoPage() {
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<li>
|
||||
<Link className="ds-link ds-link-muted" href="/">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link
|
||||
aria-current="page"
|
||||
className="ds-link ds-link-muted"
|
||||
href="/curriculo"
|
||||
>
|
||||
Currículo
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">Currículo profissional</p>
|
||||
<h1 className="ds-section-title text-white">
|
||||
Engenheiro da Computação e Desenvolvedor de Software para Garuva,
|
||||
Joinville, Curitiba e remoto.
|
||||
Engenheiro da Computação e Desenvolvedor de Software.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
||||
{portfolio.personal.summary} Estou disponível para oportunidades CLT
|
||||
e PJ, com prioridade para times e empresas do Norte de Santa
|
||||
Catarina e Curitiba que precisam de execução técnica consistente.
|
||||
Base em Garuva, atendimento a projetos no Norte de Santa Catarina e
|
||||
atuação remota. Este currículo reúne experiência profissional,
|
||||
formação e competências técnicas para avaliação de contratação.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<a className="ds-link ds-link-accent" href="/Profile.pdf" download>
|
||||
@@ -67,6 +74,9 @@ export default function CurriculoPage() {
|
||||
<Link className="ds-link ds-link-muted" href="/#contact">
|
||||
Falar sobre uma oportunidade
|
||||
</Link>
|
||||
<Link className="ds-link ds-link-muted" href="/servicos">
|
||||
Procurando consultoria? Ver serviços
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+232
-11
@@ -63,6 +63,10 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -94,9 +98,9 @@ html [id] {
|
||||
}
|
||||
|
||||
.site-header-menu-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
@@ -144,6 +148,26 @@ html [id] {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.film-grain {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9990;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-radial-gradient(
|
||||
circle at 20% 30%,
|
||||
var(--paper) 0 0.6px,
|
||||
transparent 0.8px 3px
|
||||
),
|
||||
repeating-radial-gradient(
|
||||
circle at 70% 60%,
|
||||
var(--ink) 0 0.5px,
|
||||
transparent 0.7px 4px
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
||||
.film-grain {
|
||||
display: none;
|
||||
@@ -173,10 +197,6 @@ svg {
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
@@ -506,23 +526,63 @@ textarea {
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
from {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-velocity-text {
|
||||
position: relative;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.scroll-velocity-viewport {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scroll-velocity-track,
|
||||
.scroll-velocity-group {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.scroll-velocity-group {
|
||||
flex-shrink: 0;
|
||||
min-width: 100vw;
|
||||
}
|
||||
|
||||
.scroll-velocity-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 10vw, 8rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scroll-velocity-dot {
|
||||
margin-inline: var(--sp-8);
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
@@ -531,6 +591,71 @@ textarea {
|
||||
animation: marquee-reverse 45s linear infinite;
|
||||
}
|
||||
|
||||
.scroll-velocity-text:hover .scroll-velocity-track,
|
||||
.scroll-velocity-text:focus-within .scroll-velocity-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.scroll-velocity-control {
|
||||
position: absolute;
|
||||
top: var(--sp-2);
|
||||
right: var(--sp-3);
|
||||
z-index: 1;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--slash);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
padding-inline: var(--sp-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scroll-velocity-text {
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.scroll-velocity-track {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scroll-velocity-viewport {
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.scroll-velocity-track,
|
||||
.scroll-velocity-group {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroll-velocity-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scroll-velocity-group[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scroll-velocity-item {
|
||||
display: block;
|
||||
padding: var(--sp-6);
|
||||
font-size: clamp(2rem, 8vw, 4rem);
|
||||
line-height: 1.1;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.scroll-velocity-dot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -783,9 +908,11 @@ textarea {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
@@ -798,12 +925,106 @@ textarea {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(2);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section-divider-gradient {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-divider-gradient span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, var(--slash), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.section-divider-gradient span:last-child {
|
||||
animation: shimmerDivider 3s ease-in-out infinite;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.section-divider-pulse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding-block: var(--sp-8);
|
||||
}
|
||||
|
||||
.section-divider-pulse span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--slash);
|
||||
animation: pulseDot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.section-divider-pulse span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.section-divider-pulse span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.section-divider-pulse span:nth-child(4) {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.section-divider-pulse span:nth-child(5) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.section-divider-waveform {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
.section-divider-waveform svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.section-divider-waveform path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.section-divider-waveform path:nth-child(1) {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.section-divider-waveform path:nth-child(2) {
|
||||
opacity: 0.07;
|
||||
}
|
||||
|
||||
.section-divider-waveform path:nth-child(3) {
|
||||
opacity: 0.04;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.section-divider-gradient span:last-child,
|
||||
.section-divider-pulse span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cinematic card hover effect */
|
||||
.ds-card {
|
||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useScrollReveal(options?: {
|
||||
y?: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
ease?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
scrub?: boolean | number;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
// Lazy registration to avoid Next.js hydration mismatch issues
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
ref.current,
|
||||
{ y: options?.y ?? 60, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: options?.duration ?? 0.9,
|
||||
delay: options?.delay ?? 0,
|
||||
ease: options?.ease ?? "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: options?.start ?? "top 85%",
|
||||
end: options?.end ?? "top 50%",
|
||||
scrub: options?.scrub ?? false,
|
||||
},
|
||||
},
|
||||
);
|
||||
}, ref);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [
|
||||
options?.y,
|
||||
options?.duration,
|
||||
options?.delay,
|
||||
options?.ease,
|
||||
options?.start,
|
||||
options?.end,
|
||||
options?.scrub,
|
||||
]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
+14
-202
@@ -3,19 +3,16 @@ import { Geist_Mono, Space_Grotesk } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import type { ReactNode } from "react";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { regionCities, siteUrl } from "@/lib/site-content";
|
||||
import { getWhatsAppNumber } from "@/lib/whatsapp";
|
||||
import { buildPersonJsonLd, buildWebSiteJsonLd } from "@/lib/seo";
|
||||
import { siteUrl } from "@/lib/site-content";
|
||||
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
|
||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
import { RouteTransition } from "./components/RouteTransition";
|
||||
import { AnimationProvider } from "./providers/AnimationProvider";
|
||||
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
||||
import "./globals.css";
|
||||
|
||||
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
|
||||
const seoDescription =
|
||||
"Evandro Fonseca Junior é engenheiro da computação, desenvolvedor de software e criação de sites para empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul, Norte de Santa Catarina e Curitiba. Atua com Next.js, TypeScript, Rust, IoT, integração de sistemas e produtos críticos.";
|
||||
"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"],
|
||||
@@ -54,33 +51,6 @@ export const metadata: Metadata = {
|
||||
template: "%s | Evandro Fonseca Junior",
|
||||
},
|
||||
description: seoDescription,
|
||||
keywords: [
|
||||
"desenvolvedor de software em Garuva",
|
||||
"engenheiro da computação em Garuva",
|
||||
"desenvolvedor de sites em Garuva",
|
||||
"desenvolvedor de software em Joinville",
|
||||
"engenheiro da computação em Joinville",
|
||||
"desenvolvedor de sites em Joinville",
|
||||
"criação de sites em Joinville",
|
||||
"programador em Joinville",
|
||||
"desenvolvedor de software em Curitiba",
|
||||
"engenheiro da computação em Curitiba",
|
||||
"criação de sites em Curitiba",
|
||||
"desenvolvedor de sites em Curitiba",
|
||||
"programador em Curitiba",
|
||||
"desenvolvedor de software em São Francisco do Sul",
|
||||
"desenvolvedor de software em Itapoá",
|
||||
"desenvolvedor de software em Jaraguá do Sul",
|
||||
"desenvolvedor full stack Santa Catarina",
|
||||
"engenharia de software Norte de Santa Catarina",
|
||||
"criação de sites Norte de Santa Catarina",
|
||||
"desenvolvimento de sistemas web Santa Catarina",
|
||||
"Next.js",
|
||||
"TypeScript",
|
||||
"Rust",
|
||||
"IoT",
|
||||
"arquitetura de software",
|
||||
],
|
||||
authors: [{ name: "Evandro Fonseca Junior" }],
|
||||
creator: "Evandro Fonseca Junior",
|
||||
publisher: "Evandro Fonseca Junior",
|
||||
@@ -145,159 +115,7 @@ interface RootLayoutProps {
|
||||
export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${siteUrl}/#website`,
|
||||
url: siteUrl,
|
||||
name: "Evandro.dev.br",
|
||||
inLanguage: "pt-BR",
|
||||
description: seoDescription,
|
||||
},
|
||||
{
|
||||
"@type": "ProfilePage",
|
||||
"@id": `${siteUrl}/curriculo#profile`,
|
||||
url: `${siteUrl}/curriculo`,
|
||||
inLanguage: "pt-BR",
|
||||
mainEntity: { "@id": `${siteUrl}/#person` },
|
||||
},
|
||||
{
|
||||
"@type": "Person",
|
||||
"@id": `${siteUrl}/#person`,
|
||||
name: "Evandro Fonseca Junior",
|
||||
alternateName: ["Evandro.dev", "evandrodevbr"],
|
||||
jobTitle:
|
||||
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
|
||||
description: seoDescription,
|
||||
url: siteUrl,
|
||||
image: `${siteUrl}/opengraph-image`,
|
||||
telephone: `+${getWhatsAppNumber()}`,
|
||||
sameAs: [
|
||||
"https://github.com/evandrodevbr",
|
||||
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||
"https://www.instagram.com/evandrodevbr/",
|
||||
],
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: "Garuva",
|
||||
addressRegion: "SC",
|
||||
addressCountry: "BR",
|
||||
},
|
||||
homeLocation: {
|
||||
"@type": "Place",
|
||||
name: "Garuva, Santa Catarina, Brasil",
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: -26.0281,
|
||||
longitude: -48.8467,
|
||||
},
|
||||
},
|
||||
hasOccupation: {
|
||||
"@type": "Occupation",
|
||||
name: "Engenheiro da Computação e Desenvolvedor de Software",
|
||||
occupationalCategory: "15-1252.00 Software Developers",
|
||||
},
|
||||
knowsAbout: [
|
||||
"Engenharia de Software",
|
||||
"Next.js",
|
||||
"Tauri",
|
||||
"Rust",
|
||||
"Arquitetura de Software",
|
||||
"Sistemas Críticos",
|
||||
"Integração hardware e software",
|
||||
"IoT",
|
||||
"TypeScript",
|
||||
"Cloud Computing",
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ProfessionalService",
|
||||
"@id": `${siteUrl}/#services`,
|
||||
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Técnica",
|
||||
url: siteUrl,
|
||||
image: `${siteUrl}/opengraph-image`,
|
||||
telephone: `+${getWhatsAppNumber()}`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: "Garuva",
|
||||
addressRegion: "SC",
|
||||
addressCountry: "BR",
|
||||
},
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: -26.0281,
|
||||
longitude: -48.8467,
|
||||
},
|
||||
description:
|
||||
"Serviço de engenharia de software, criação de sites, arquitetura de software, integrações críticas e consultoria técnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
|
||||
areaServed: [
|
||||
...regionCities.map((name) => ({ "@type": "City", name })),
|
||||
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
|
||||
{ "@type": "AdministrativeArea", name: "Paraná" },
|
||||
{
|
||||
"@type": "GeoCircle",
|
||||
geoMidpoint: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: -26.0281,
|
||||
longitude: -48.8467,
|
||||
},
|
||||
geoRadius: "150000",
|
||||
},
|
||||
{ "@type": "Country", name: "Brasil" },
|
||||
{ "@type": "Place", name: "Atuação remota" },
|
||||
],
|
||||
provider: {
|
||||
"@id": `${siteUrl}/#person`,
|
||||
},
|
||||
serviceType: [
|
||||
"Engenharia de Software",
|
||||
"Criação de Sites",
|
||||
"Consultoria Técnica",
|
||||
"Arquitetura de Software",
|
||||
"Integração hardware + software",
|
||||
"Desenvolvimento Next.js",
|
||||
"Desenvolvimento Tauri",
|
||||
"Desenvolvimento Rust",
|
||||
"IoT",
|
||||
"Sistemas Críticos",
|
||||
],
|
||||
hasOfferCatalog: {
|
||||
"@type": "OfferCatalog",
|
||||
name: "Modelos de contratação",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
"@type": "Service",
|
||||
name: "Criação de sites institucionais e sistemas web",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
"@type": "Service",
|
||||
name: "Auditoria e diagnóstico técnico",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
"@type": "Service",
|
||||
name: "Ciclos de engenharia",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
"@type": "Service",
|
||||
name: "Fractional Tech Lead",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"@graph": [buildWebSiteJsonLd(), buildPersonJsonLd()],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -306,22 +124,16 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
>
|
||||
<body>
|
||||
<SmoothScrollProvider>
|
||||
<AnimationProvider>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">
|
||||
Pular para o conteúdo
|
||||
</a>
|
||||
<Header />
|
||||
<CinematicOverlays />
|
||||
<RouteTransition>
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
</RouteTransition>
|
||||
<GoogleAnalytics />
|
||||
</AnimationProvider>
|
||||
</SmoothScrollProvider>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">
|
||||
Pular para o conteúdo
|
||||
</a>
|
||||
<Header />
|
||||
<CinematicOverlays />
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
<GoogleAnalytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Animation Configuration
|
||||
* Centralized configuration for all animations in the portfolio
|
||||
*/
|
||||
|
||||
export const animationConfig = {
|
||||
// Easing curves
|
||||
easings: {
|
||||
easeOut: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
|
||||
spring: {
|
||||
default: { stiffness: 200, damping: 20 },
|
||||
hover: { stiffness: 260, damping: 20 },
|
||||
magnetic: { stiffness: 150, damping: 15 },
|
||||
gentle: { stiffness: 60, damping: 20 },
|
||||
},
|
||||
},
|
||||
|
||||
// Durations (in seconds)
|
||||
durations: {
|
||||
instant: 0.15,
|
||||
fast: 0.3,
|
||||
normal: 0.6,
|
||||
slow: 0.9,
|
||||
verySlow: 1.2,
|
||||
},
|
||||
|
||||
// Scroll trigger
|
||||
scrollTrigger: {
|
||||
threshold: 0.2,
|
||||
once: true,
|
||||
margin: "0px",
|
||||
},
|
||||
|
||||
// Stagger
|
||||
stagger: {
|
||||
default: 0.08,
|
||||
fast: 0.04,
|
||||
slow: 0.12,
|
||||
reducedMotion: 0.03,
|
||||
},
|
||||
|
||||
// Parallax
|
||||
parallax: {
|
||||
speed: 0.5, // 50% mais lento que scroll normal
|
||||
maxDistance: 100, // pixels
|
||||
},
|
||||
|
||||
// Magnetic effect
|
||||
magnetic: {
|
||||
radius: 80, // pixels
|
||||
strength: 12, // max movement in pixels
|
||||
},
|
||||
|
||||
// Cursor
|
||||
cursor: {
|
||||
size: 40,
|
||||
expandedSize: 60,
|
||||
followDelay: 0.1,
|
||||
},
|
||||
|
||||
// Reduced motion overrides
|
||||
reducedMotion: {
|
||||
duration: 0.15,
|
||||
stagger: 0.03,
|
||||
disableParallax: true,
|
||||
disableMagnetic: true,
|
||||
disableCursor: true,
|
||||
},
|
||||
} as const;
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Animation Variants
|
||||
* Preset variants for Framer Motion animations
|
||||
*/
|
||||
|
||||
export const variants = {
|
||||
// Fade in
|
||||
fadeIn: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
|
||||
// Fade in + slide up
|
||||
fadeInUp: {
|
||||
initial: { opacity: 0, y: 24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: -24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Fade in + slide down
|
||||
fadeInDown: {
|
||||
initial: { opacity: 0, y: -24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: 24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Fade in + slide left
|
||||
fadeInLeft: {
|
||||
initial: { opacity: 0, x: 24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, x: -24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Fade in + slide right
|
||||
fadeInRight: {
|
||||
initial: { opacity: 0, x: -24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, x: 24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Scale in
|
||||
scaleIn: {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.95 },
|
||||
},
|
||||
|
||||
// Image reveal
|
||||
imageReveal: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
filter: "blur(8px)",
|
||||
clipPath: "inset(0 100% 0 0)",
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
},
|
||||
},
|
||||
|
||||
// Text reveal (per word)
|
||||
textReveal: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
filter: "blur(6px)",
|
||||
clipPath: "inset(0 100% 0 0)",
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
},
|
||||
},
|
||||
|
||||
// Stagger container
|
||||
staggerContainer: {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Reduced motion variants
|
||||
reducedMotion: {
|
||||
fadeIn: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
+9
-40
@@ -1,49 +1,18 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
|
||||
export const alt =
|
||||
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
export const contentType = "image/png";
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = OG_CONTENT_TYPE;
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
background: "#030712",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontFamily: "system-ui",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 64,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Evandro Fonseca Junior
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "#3b82f6",
|
||||
marginTop: 10,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Engenheiro da Computação e Desenvolvedor de Software
|
||||
</div>
|
||||
</div>,
|
||||
{ ...size },
|
||||
<OgCard
|
||||
eyebrow="Portfólio de engenharia de software"
|
||||
title="Evandro Fonseca Junior"
|
||||
description="Engenheiro da Computação e Desenvolvedor de Software"
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
}
|
||||
+2
-4
@@ -1,12 +1,10 @@
|
||||
import { loadLiveFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<CinematicHome
|
||||
projects={await loadLiveFeaturedProjects()}
|
||||
projects={await loadFeaturedProjects()}
|
||||
experiences={portfolio.experiences}
|
||||
skillCategories={portfolio.skills.technical}
|
||||
/>
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AnimationProvider, useAnimationConfig } from "./AnimationProvider";
|
||||
|
||||
// Mock do useReducedMotion
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
|
||||
// Test component that uses the hook
|
||||
function TestComponent() {
|
||||
const config = useAnimationConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="reduced-motion">{String(config.reducedMotion)}</div>
|
||||
<div data-testid="ease-out">{config.easings.easeOut}</div>
|
||||
<div data-testid="duration-normal">{config.durations.normal}</div>
|
||||
<div data-testid="spring-default-stiffness">
|
||||
{config.easings.spring.default.stiffness}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("AnimationProvider", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("provides animation config with reducedMotion false", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("reduced-motion")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("ease-out")).toHaveTextContent(
|
||||
"cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
);
|
||||
expect(screen.getByTestId("duration-normal")).toHaveTextContent("0.6");
|
||||
expect(screen.getByTestId("spring-default-stiffness")).toHaveTextContent(
|
||||
"200",
|
||||
);
|
||||
});
|
||||
|
||||
it("provides animation config with reducedMotion true", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("reduced-motion")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("provides all easing configurations", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
const config = screen.getByTestId("ease-out").textContent;
|
||||
expect(config).toBe("cubic-bezier(0.16, 1, 0.3, 1)");
|
||||
});
|
||||
|
||||
it("provides all duration configurations", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
const duration = screen.getByTestId("duration-normal").textContent;
|
||||
expect(duration).toBe("0.6");
|
||||
});
|
||||
|
||||
it("throws error when useAnimationConfig is used outside provider", () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleError = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow("useAnimationConfig must be used within AnimationProvider");
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAnimationConfig", () => {
|
||||
it("returns config object with correct structure", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
function ConfigStructureTest() {
|
||||
const config = useAnimationConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="has-reduced-motion">
|
||||
{String("reducedMotion" in config)}
|
||||
</div>
|
||||
<div data-testid="has-easings">{String("easings" in config)}</div>
|
||||
<div data-testid="has-durations">{String("durations" in config)}</div>
|
||||
<div data-testid="has-spring">
|
||||
{String("spring" in config.easings)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<ConfigStructureTest />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("has-reduced-motion")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("has-easings")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("has-durations")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("has-spring")).toHaveTextContent("true");
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { animationConfig } from "@/app/lib/animations/config";
|
||||
|
||||
/**
|
||||
* Animation Configuration Context
|
||||
* Provides global animation settings including reduced motion detection
|
||||
*/
|
||||
interface AnimationConfig {
|
||||
reducedMotion: boolean;
|
||||
easings: typeof animationConfig.easings;
|
||||
durations: typeof animationConfig.durations;
|
||||
}
|
||||
|
||||
const AnimationContext = createContext<AnimationConfig | undefined>(undefined);
|
||||
|
||||
interface AnimationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimationProvider
|
||||
*
|
||||
* Provider component that supplies global animation configuration through React context.
|
||||
* Automatically detects user's reduced motion preference and provides centralized
|
||||
* animation settings (easings, durations) to all child components.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In app/layout.tsx
|
||||
* <AnimationProvider>
|
||||
* <YourApp />
|
||||
* </AnimationProvider>
|
||||
* ```
|
||||
*
|
||||
* @see useAnimationConfig - Hook to consume this context
|
||||
*/
|
||||
export function AnimationProvider({ children }: AnimationProviderProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const config: AnimationConfig = {
|
||||
reducedMotion,
|
||||
easings: animationConfig.easings,
|
||||
durations: animationConfig.durations,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimationContext.Provider value={config}>
|
||||
{children}
|
||||
</AnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useAnimationConfig
|
||||
*
|
||||
* Hook to access global animation configuration from AnimationProvider context.
|
||||
* Provides reduced motion state, easing curves, and duration presets.
|
||||
*
|
||||
* @returns {AnimationConfig} Animation configuration object
|
||||
* @throws {Error} If used outside of AnimationProvider
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { reducedMotion, easings, durations } = useAnimationConfig();
|
||||
*
|
||||
* return (
|
||||
* <motion.div
|
||||
* animate={{
|
||||
* opacity: 1,
|
||||
* y: reducedMotion ? 0 : 20
|
||||
* }}
|
||||
* transition={{
|
||||
* duration: durations.normal,
|
||||
* ease: easings.easeOut
|
||||
* }}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAnimationConfig(): AnimationConfig {
|
||||
const context = useContext(AnimationContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useAnimationConfig must be used within AnimationProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function SmoothScrollProvider({ children }: { children: ReactNode }) {
|
||||
const teardownRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const hasCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
if (prefersReducedMotion || hasCoarsePointer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const setupSmoothScroll = async () => {
|
||||
const [{ default: Lenis }, { gsap }, { ScrollTrigger }] =
|
||||
await Promise.all([
|
||||
import("lenis"),
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
const handleScroll = () => ScrollTrigger.update();
|
||||
const tick = (time: number) => {
|
||||
lenis.raf(time * 1000);
|
||||
};
|
||||
|
||||
lenis.on("scroll", handleScroll);
|
||||
gsap.ticker.add(tick);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
teardownRef.current = () => {
|
||||
lenis.off?.("scroll", handleScroll);
|
||||
gsap.ticker.remove(tick);
|
||||
lenis.destroy();
|
||||
};
|
||||
};
|
||||
|
||||
void setupSmoothScroll();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
teardownRef.current();
|
||||
teardownRef.current = () => {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
import { getServicePage, servicePages } from "@/lib/site-content";
|
||||
|
||||
function getImageMetadata(service: (typeof servicePages)[number]) {
|
||||
return {
|
||||
id: service.slug,
|
||||
alt: `${service.navLabel} — Evandro Fonseca Junior`,
|
||||
size: OG_SIZE,
|
||||
contentType: OG_CONTENT_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateImageMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug?: string };
|
||||
}) {
|
||||
if (!params.slug) {
|
||||
return servicePages.map(getImageMetadata);
|
||||
}
|
||||
|
||||
const service = getServicePage(params.slug);
|
||||
if (!service) notFound();
|
||||
return [getImageMetadata(service)];
|
||||
}
|
||||
|
||||
export default async function Image({
|
||||
params,
|
||||
id,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
id: Promise<string | number>;
|
||||
}) {
|
||||
const [{ slug }, imageId] = await Promise.all([params, id]);
|
||||
const service = getServicePage(slug);
|
||||
if (!service || imageId !== service.slug) notFound();
|
||||
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow={service.eyebrow}
|
||||
title={service.headline}
|
||||
description={service.answer}
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { SEO_IDS } from "@/lib/seo";
|
||||
import { caseStudies, getServicePage, siteUrl } from "@/lib/site-content";
|
||||
import ServicesIndexPage from "../page";
|
||||
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
|
||||
|
||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||
|
||||
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
as = "button",
|
||||
strength: _strength,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
as?: string;
|
||||
strength?: number;
|
||||
children?: ReactNode;
|
||||
} & Record<string, unknown>) => {
|
||||
const React = require("react");
|
||||
return React.createElement(as, props, children);
|
||||
},
|
||||
}));
|
||||
|
||||
const service = getServicePage("consultoria-nextjs");
|
||||
if (!service) throw new Error("Missing representative service");
|
||||
|
||||
const publicServiceSlugs = [
|
||||
"criacao-de-sites",
|
||||
"engenharia-de-software",
|
||||
"consultoria-nextjs",
|
||||
"tauri-rust",
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
] as const;
|
||||
const publicServiceHrefs = publicServiceSlugs.map(
|
||||
(slug) => `/servicos/${slug}`,
|
||||
);
|
||||
|
||||
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||
const scripts = container.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]',
|
||||
);
|
||||
if (scripts.length === 0) throw new Error("Missing JSON-LD");
|
||||
|
||||
return Array.from(scripts).flatMap((script) => {
|
||||
if (!script.textContent) throw new Error("Empty JSON-LD");
|
||||
const value = JSON.parse(script.textContent) as Record<string, unknown>;
|
||||
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps static params and route metadata canonical", async () => {
|
||||
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
||||
expect(staticSlugs).toHaveLength(6);
|
||||
expect(new Set(staticSlugs).size).toBe(6);
|
||||
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
|
||||
|
||||
const metadata = await generateMetadata({
|
||||
params: Promise.resolve({ slug: service.slug }),
|
||||
});
|
||||
const url = `${siteUrl}/servicos/${service.slug}`;
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
title: service.seoTitle,
|
||||
description: service.metaDescription,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: service.seoTitle,
|
||||
description: service.metaDescription,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title: service.seoTitle,
|
||||
description: service.metaDescription,
|
||||
},
|
||||
});
|
||||
expect(metadata).not.toHaveProperty("keywords");
|
||||
});
|
||||
|
||||
it("renders answer-first consulting, reciprocal case links and minimal schema", async () => {
|
||||
const ui = await ServicePage({
|
||||
params: Promise.resolve({ slug: service.slug }),
|
||||
});
|
||||
const { container } = render(ui);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
const answer = screen.getByText(service.answer);
|
||||
expect(answer).toBeVisible();
|
||||
expect(heading.nextElementSibling).toBe(answer);
|
||||
|
||||
const primaryCtas = screen.getAllByRole("link", {
|
||||
name: service.ctaLabel,
|
||||
});
|
||||
expect(primaryCtas).toHaveLength(2);
|
||||
for (const cta of primaryCtas) {
|
||||
expect(cta).toHaveAttribute("href", "/#contact");
|
||||
}
|
||||
|
||||
const expectedCaseLinks = service.relatedCaseIds
|
||||
.map((id) => {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing related case ${id}`);
|
||||
return {
|
||||
name: `Ler case: ${caseStudy.title}`,
|
||||
href: `/cases/${id}`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.href.localeCompare(b.href));
|
||||
const internalCaseLinks = screen.getAllByRole("link", {
|
||||
name: /^Ler case:/,
|
||||
});
|
||||
expect(
|
||||
internalCaseLinks
|
||||
.map((link) => ({
|
||||
name: link.textContent,
|
||||
href: link.getAttribute("href"),
|
||||
}))
|
||||
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
|
||||
).toEqual(expectedCaseLinks);
|
||||
for (const internalLink of internalCaseLinks) {
|
||||
const firstExternalLink = internalLink
|
||||
.closest("article")
|
||||
?.querySelector('a[target="_blank"]');
|
||||
if (firstExternalLink) {
|
||||
expect(
|
||||
internalLink.compareDocumentPosition(firstExternalLink) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
}
|
||||
}
|
||||
|
||||
const externalLinks = container.querySelectorAll('a[target="_blank"]');
|
||||
expect(externalLinks.length).toBeGreaterThan(0);
|
||||
for (const externalLink of externalLinks) {
|
||||
expect(externalLink).toHaveAttribute(
|
||||
"rel",
|
||||
expect.stringMatching(/noreferrer/),
|
||||
);
|
||||
}
|
||||
|
||||
const graph = readJsonLdEntities(container);
|
||||
expect(graph.map((node) => node["@type"])).toEqual([
|
||||
"Service",
|
||||
"BreadcrumbList",
|
||||
]);
|
||||
expect(JSON.stringify(graph)).not.toMatch(
|
||||
/Article|FAQPage|ProfessionalService/,
|
||||
);
|
||||
const serviceSchema = graph.find((node) => node["@type"] === "Service");
|
||||
expect(serviceSchema).toMatchObject({
|
||||
provider: { "@id": SEO_IDS.person },
|
||||
});
|
||||
const breadcrumb = graph.find(
|
||||
(node) => node["@type"] === "BreadcrumbList",
|
||||
) as {
|
||||
itemListElement: Array<{
|
||||
"@type": string;
|
||||
position: number;
|
||||
name: string;
|
||||
item: string;
|
||||
}>;
|
||||
};
|
||||
expect(breadcrumb.itemListElement).toEqual([
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Serviços",
|
||||
item: `${siteUrl}/servicos`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: service.navLabel,
|
||||
item: `${siteUrl}/servicos/${service.slug}`,
|
||||
},
|
||||
]);
|
||||
|
||||
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"],
|
||||
[service.navLabel, `/servicos/${service.slug}`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps hub cards and structured items aligned", () => {
|
||||
const { container } = render(<ServicesIndexPage />);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.nextElementSibling).toHaveTextContent(
|
||||
/desenvolvimento.+consultoria.+engenharia de software/i,
|
||||
);
|
||||
|
||||
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",
|
||||
"ItemList",
|
||||
"BreadcrumbList",
|
||||
]);
|
||||
const itemList = graph[1] as {
|
||||
itemListElement: Array<{ position: number; url: string }>;
|
||||
};
|
||||
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
|
||||
1, 2, 3, 4, 5, 6,
|
||||
]);
|
||||
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
|
||||
expect(structuredUrls).toHaveLength(6);
|
||||
expect(new Set(structuredUrls).size).toBe(6);
|
||||
expect([...structuredUrls].sort()).toEqual(
|
||||
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
|
||||
);
|
||||
const visibleCards = screen.getAllByRole("link", { name: "Abrir página" });
|
||||
const visibleHrefs = visibleCards.map((link) => link.getAttribute("href"));
|
||||
expect(visibleHrefs).toHaveLength(6);
|
||||
expect(new Set(visibleHrefs).size).toBe(6);
|
||||
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
|
||||
expect(graph[2]).toMatchObject({
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ position: 1, name: "Início", item: siteUrl },
|
||||
{ position: 2, name: "Serviços", item: `${siteUrl}/servicos` },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
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 { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildServiceJsonLd } from "@/lib/seo";
|
||||
import {
|
||||
getRelatedCases,
|
||||
getServicePage,
|
||||
regionCities,
|
||||
servicePages,
|
||||
siteName,
|
||||
siteUrl,
|
||||
@@ -71,54 +72,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Service",
|
||||
"@id": `${pageUrl}#service`,
|
||||
name: service.navLabel,
|
||||
description: service.metaDescription,
|
||||
serviceType: service.navLabel,
|
||||
provider: {
|
||||
"@type": "Person",
|
||||
name: "Evandro Fonseca Junior",
|
||||
url: siteUrl,
|
||||
},
|
||||
areaServed: [
|
||||
...regionCities.map((name) => ({ "@type": "City", name })),
|
||||
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
|
||||
{
|
||||
"@type": "Country",
|
||||
name: "Brasil",
|
||||
},
|
||||
{
|
||||
"@type": "Place",
|
||||
name: "Remote worldwide",
|
||||
},
|
||||
],
|
||||
url: pageUrl,
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Início",
|
||||
item: siteUrl,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Serviços",
|
||||
item: `${siteUrl}/servicos`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: service.navLabel,
|
||||
item: pageUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildServiceJsonLd(service),
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Serviços", `${siteUrl}/servicos`],
|
||||
[service.navLabel, pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -130,12 +89,37 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
<HeroBackground />
|
||||
<div className="ds-section-shell relative z-10">
|
||||
<div className="max-w-[860px]">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<li>
|
||||
<Link className="ds-link ds-link-muted" href="/">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link className="ds-link ds-link-muted" href="/servicos">
|
||||
Serviços
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link
|
||||
aria-current="page"
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/servicos/${service.slug}`}
|
||||
>
|
||||
{service.navLabel}
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">{service.eyebrow}</p>
|
||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
||||
{service.headline}
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
{service.intro}
|
||||
{service.answer}
|
||||
</p>
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton
|
||||
@@ -268,6 +252,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className="ds-link ds-link-accent"
|
||||
>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
|
||||
+47
-39
@@ -1,26 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import {
|
||||
serviceHubLinks,
|
||||
servicePages,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import { buildBreadcrumbList } from "@/lib/seo";
|
||||
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/servicos`;
|
||||
const pageTitle = "Serviços de Engenharia de Software e Consultoria Técnica";
|
||||
const pageDescription =
|
||||
"Desenvolvimento e consultoria em engenharia de software, Next.js, Tauri, Rust, arquitetura e integração hardware + software para produtos e sistemas críticos.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Serviços de Engenharia de Software e Consultoria Técnica",
|
||||
description:
|
||||
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/servicos`,
|
||||
canonical: pageUrl,
|
||||
},
|
||||
openGraph: {
|
||||
title: "Serviços de Engenharia de Software e Consultoria Técnica",
|
||||
description:
|
||||
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
|
||||
url: `${siteUrl}/servicos`,
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
url: pageUrl,
|
||||
siteName,
|
||||
type: "website",
|
||||
},
|
||||
@@ -32,33 +32,24 @@ export default function ServicesIndexPage() {
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "CollectionPage",
|
||||
"@id": `${siteUrl}/servicos#page`,
|
||||
name: "Serviços de Engenharia de Software e Consultoria Técnica",
|
||||
url: `${siteUrl}/servicos`,
|
||||
description:
|
||||
"Hub de serviços para engenharia de software, consultoria técnica, Next.js, Tauri, Rust, IoT e arquitetura de software.",
|
||||
"@id": `${pageUrl}#page`,
|
||||
name: pageTitle,
|
||||
url: pageUrl,
|
||||
description: pageDescription,
|
||||
},
|
||||
{
|
||||
"@type": "ItemList",
|
||||
itemListElement: servicePages.map((service, index) => ({
|
||||
itemListElement: serviceHubLinks.map((service, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: `${siteUrl}/servicos/${service.slug}`,
|
||||
name: service.navLabel,
|
||||
url: `${siteUrl}${service.href}`,
|
||||
name: service.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Serviços",
|
||||
item: `${siteUrl}/servicos`,
|
||||
},
|
||||
],
|
||||
},
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Serviços", pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -70,15 +61,32 @@ export default function ServicesIndexPage() {
|
||||
<HeroBackground />
|
||||
<div className="ds-section-shell relative z-10">
|
||||
<div className="max-w-[860px]">
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<li>
|
||||
<Link className="ds-link ds-link-muted" href="/">
|
||||
Início
|
||||
</Link>
|
||||
</li>
|
||||
<li className="flex items-center gap-sp-2">
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link
|
||||
aria-current="page"
|
||||
className="ds-link ds-link-muted"
|
||||
href="/servicos"
|
||||
>
|
||||
Serviços
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p className="ds-eyebrow">Hub de serviços</p>
|
||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
||||
Serviços de engenharia de software para contextos técnicos de
|
||||
alta exigência.
|
||||
Serviços de engenharia de software para contextos técnicos de alta
|
||||
exigência.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
Organizei as páginas de serviço por intenção real de compra:
|
||||
engenharia de software, consultoria Next.js, Tauri e Rust,
|
||||
arquitetura de software e integrações hardware + software.
|
||||
{pageDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+8
-36
@@ -2,44 +2,16 @@ import type { MetadataRoute } from "next";
|
||||
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const now = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
url: siteUrl,
|
||||
lastModified: now,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/curriculo`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.95,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/servicos`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/cases`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
...servicePages.map((service) => ({
|
||||
url: `${siteUrl}/servicos/${service.slug}`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
{ url: siteUrl },
|
||||
{ url: `${siteUrl}/curriculo` },
|
||||
{ url: `${siteUrl}/servicos` },
|
||||
{ url: `${siteUrl}/cases` },
|
||||
...servicePages.map(({ slug }) => ({
|
||||
url: `${siteUrl}/servicos/${slug}`,
|
||||
})),
|
||||
...caseStudies.map((caseStudy) => ({
|
||||
url: `${siteUrl}/cases/${caseStudy.id}`,
|
||||
lastModified: now,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.75,
|
||||
...caseStudies.map(({ id }) => ({
|
||||
url: `${siteUrl}/cases/${id}`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
"use client";
|
||||
import { gsap } from "gsap";
|
||||
import { InertiaPlugin } from "gsap/InertiaPlugin";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
gsap.registerPlugin(InertiaPlugin);
|
||||
|
||||
const throttle = (func: (...args: any[]) => void, limit: number) => {
|
||||
let lastCall = 0;
|
||||
return function (this: any, ...args: any[]) {
|
||||
const now = performance.now();
|
||||
if (now - lastCall >= limit) {
|
||||
lastCall = now;
|
||||
func.apply(this, args);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface Dot {
|
||||
cx: number;
|
||||
cy: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
_inertiaApplied: boolean;
|
||||
}
|
||||
|
||||
export interface DotGridProps {
|
||||
dotSize?: number;
|
||||
gap?: number;
|
||||
baseColor?: string;
|
||||
activeColor?: string;
|
||||
proximity?: number;
|
||||
speedTrigger?: number;
|
||||
shockRadius?: number;
|
||||
shockStrength?: number;
|
||||
maxSpeed?: number;
|
||||
resistance?: number;
|
||||
returnDuration?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
return {
|
||||
r: parseInt(m[1], 16),
|
||||
g: parseInt(m[2], 16),
|
||||
b: parseInt(m[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
const DotGrid: React.FC<DotGridProps> = ({
|
||||
dotSize = 16,
|
||||
gap = 32,
|
||||
baseColor = "#5227FF",
|
||||
activeColor = "#5227FF",
|
||||
proximity = 150,
|
||||
speedTrigger = 100,
|
||||
shockRadius = 250,
|
||||
shockStrength = 5,
|
||||
maxSpeed = 5000,
|
||||
resistance = 750,
|
||||
returnDuration = 1.5,
|
||||
className = "",
|
||||
style,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const dotsRef = useRef<Dot[]>([]);
|
||||
const pointerRef = useRef({
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
speed: 0,
|
||||
lastTime: 0,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
});
|
||||
|
||||
const baseRgb = useMemo(() => hexToRgb(baseColor), [baseColor]);
|
||||
const activeRgb = useMemo(() => hexToRgb(activeColor), [activeColor]);
|
||||
|
||||
const circlePath = useMemo(() => {
|
||||
if (typeof window === "undefined" || !window.Path2D) return null;
|
||||
|
||||
const p = new Path2D();
|
||||
p.arc(0, 0, dotSize / 2, 0, Math.PI * 2);
|
||||
return p;
|
||||
}, [dotSize]);
|
||||
|
||||
const buildGrid = useCallback(() => {
|
||||
const wrap = wrapperRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!wrap || !canvas) return;
|
||||
|
||||
const { width, height } = wrap.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) ctx.scale(dpr, dpr);
|
||||
|
||||
const cols = Math.floor((width + gap) / (dotSize + gap));
|
||||
const rows = Math.floor((height + gap) / (dotSize + gap));
|
||||
const cell = dotSize + gap;
|
||||
|
||||
const gridW = cell * cols - gap;
|
||||
const gridH = cell * rows - gap;
|
||||
|
||||
const extraX = width - gridW;
|
||||
const extraY = height - gridH;
|
||||
|
||||
const startX = extraX / 2 + dotSize / 2;
|
||||
const startY = extraY / 2 + dotSize / 2;
|
||||
|
||||
const dots: Dot[] = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const cx = startX + x * cell;
|
||||
const cy = startY + y * cell;
|
||||
dots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });
|
||||
}
|
||||
}
|
||||
dotsRef.current = dots;
|
||||
}, [dotSize, gap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!circlePath) return;
|
||||
|
||||
let rafId: number;
|
||||
const proxSq = proximity * proximity;
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const { x: px, y: py } = pointerRef.current;
|
||||
|
||||
for (const dot of dotsRef.current) {
|
||||
const ox = dot.cx + dot.xOffset;
|
||||
const oy = dot.cy + dot.yOffset;
|
||||
const dx = dot.cx - px;
|
||||
const dy = dot.cy - py;
|
||||
const dsq = dx * dx + dy * dy;
|
||||
|
||||
let style = baseColor;
|
||||
if (dsq <= proxSq) {
|
||||
const dist = Math.sqrt(dsq);
|
||||
const t = 1 - dist / proximity;
|
||||
const r = Math.round(baseRgb.r + (activeRgb.r - baseRgb.r) * t);
|
||||
const g = Math.round(baseRgb.g + (activeRgb.g - baseRgb.g) * t);
|
||||
const b = Math.round(baseRgb.b + (activeRgb.b - baseRgb.b) * t);
|
||||
style = `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(ox, oy);
|
||||
ctx.fillStyle = style;
|
||||
ctx.fill(circlePath);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [proximity, baseColor, activeRgb, baseRgb, circlePath]);
|
||||
|
||||
useEffect(() => {
|
||||
buildGrid();
|
||||
let ro: ResizeObserver | null = null;
|
||||
if ("ResizeObserver" in window) {
|
||||
ro = new ResizeObserver(buildGrid);
|
||||
wrapperRef.current && ro.observe(wrapperRef.current);
|
||||
} else {
|
||||
(window as Window).addEventListener("resize", buildGrid);
|
||||
}
|
||||
return () => {
|
||||
if (ro) ro.disconnect();
|
||||
else window.removeEventListener("resize", buildGrid);
|
||||
};
|
||||
}, [buildGrid]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const now = performance.now();
|
||||
const pr = pointerRef.current;
|
||||
const dt = pr.lastTime ? now - pr.lastTime : 16;
|
||||
const dx = e.clientX - pr.lastX;
|
||||
const dy = e.clientY - pr.lastY;
|
||||
let vx = (dx / dt) * 1000;
|
||||
let vy = (dy / dt) * 1000;
|
||||
let speed = Math.hypot(vx, vy);
|
||||
if (speed > maxSpeed) {
|
||||
const scale = maxSpeed / speed;
|
||||
vx *= scale;
|
||||
vy *= scale;
|
||||
speed = maxSpeed;
|
||||
}
|
||||
pr.lastTime = now;
|
||||
pr.lastX = e.clientX;
|
||||
pr.lastY = e.clientY;
|
||||
pr.vx = vx;
|
||||
pr.vy = vy;
|
||||
pr.speed = speed;
|
||||
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
pr.x = e.clientX - rect.left;
|
||||
pr.y = e.clientY - rect.top;
|
||||
|
||||
for (const dot of dotsRef.current) {
|
||||
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);
|
||||
if (speed > speedTrigger && dist < proximity && !dot._inertiaApplied) {
|
||||
dot._inertiaApplied = true;
|
||||
gsap.killTweensOf(dot);
|
||||
const pushX = dot.cx - pr.x + vx * 0.005;
|
||||
const pushY = dot.cy - pr.y + vy * 0.005;
|
||||
gsap.to(dot, {
|
||||
inertia: { xOffset: pushX, yOffset: pushY, resistance },
|
||||
onComplete: () => {
|
||||
gsap.to(dot, {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
duration: returnDuration,
|
||||
ease: "elastic.out(1,0.75)",
|
||||
});
|
||||
dot._inertiaApplied = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
for (const dot of dotsRef.current) {
|
||||
const dist = Math.hypot(dot.cx - cx, dot.cy - cy);
|
||||
if (dist < shockRadius && !dot._inertiaApplied) {
|
||||
dot._inertiaApplied = true;
|
||||
gsap.killTweensOf(dot);
|
||||
const falloff = Math.max(0, 1 - dist / shockRadius);
|
||||
const pushX = (dot.cx - cx) * shockStrength * falloff;
|
||||
const pushY = (dot.cy - cy) * shockStrength * falloff;
|
||||
gsap.to(dot, {
|
||||
inertia: { xOffset: pushX, yOffset: pushY, resistance },
|
||||
onComplete: () => {
|
||||
gsap.to(dot, {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
duration: returnDuration,
|
||||
ease: "elastic.out(1,0.75)",
|
||||
});
|
||||
dot._inertiaApplied = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const throttledMove = throttle(onMove, 50);
|
||||
window.addEventListener("mousemove", throttledMove, { passive: true });
|
||||
window.addEventListener("click", onClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", throttledMove);
|
||||
window.removeEventListener("click", onClick);
|
||||
};
|
||||
}, [
|
||||
maxSpeed,
|
||||
speedTrigger,
|
||||
proximity,
|
||||
resistance,
|
||||
returnDuration,
|
||||
shockRadius,
|
||||
shockStrength,
|
||||
]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`p-4 flex items-center justify-center h-full w-full relative ${className}`}
|
||||
style={style}
|
||||
>
|
||||
<div ref={wrapperRef} className="w-full h-full relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DotGrid;
|
||||
@@ -1,446 +0,0 @@
|
||||
"use client";
|
||||
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
type Vec2 = [number, number];
|
||||
|
||||
export interface FaultyTerminalProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
scale?: number;
|
||||
gridMul?: Vec2;
|
||||
digitSize?: number;
|
||||
timeScale?: number;
|
||||
pause?: boolean;
|
||||
scanlineIntensity?: number;
|
||||
glitchAmount?: number;
|
||||
flickerAmount?: number;
|
||||
noiseAmp?: number;
|
||||
chromaticAberration?: number;
|
||||
dither?: number | boolean;
|
||||
curvature?: number;
|
||||
tint?: string;
|
||||
mouseReact?: boolean;
|
||||
mouseStrength?: number;
|
||||
dpr?: number;
|
||||
pageLoadAnimation?: boolean;
|
||||
brightness?: number;
|
||||
}
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision mediump float;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float uScale;
|
||||
|
||||
uniform vec2 uGridMul;
|
||||
uniform float uDigitSize;
|
||||
uniform float uScanlineIntensity;
|
||||
uniform float uGlitchAmount;
|
||||
uniform float uFlickerAmount;
|
||||
uniform float uNoiseAmp;
|
||||
uniform float uChromaticAberration;
|
||||
uniform float uDither;
|
||||
uniform float uCurvature;
|
||||
uniform vec3 uTint;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseStrength;
|
||||
uniform float uUseMouse;
|
||||
uniform float uPageLoadProgress;
|
||||
uniform float uUsePageLoadAnimation;
|
||||
uniform float uBrightness;
|
||||
|
||||
float time;
|
||||
|
||||
float hash21(vec2 p){
|
||||
p = fract(p * 234.56);
|
||||
p += dot(p, p + 34.56);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float noise(vec2 p)
|
||||
{
|
||||
return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
|
||||
}
|
||||
|
||||
mat2 rotate(float angle)
|
||||
{
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
return mat2(c, -s, s, c);
|
||||
}
|
||||
|
||||
float fbm(vec2 p)
|
||||
{
|
||||
p *= 1.1;
|
||||
float f = 0.0;
|
||||
float amp = 0.5 * uNoiseAmp;
|
||||
|
||||
mat2 modify0 = rotate(time * 0.02);
|
||||
f += amp * noise(p);
|
||||
p = modify0 * p * 2.0;
|
||||
amp *= 0.454545;
|
||||
|
||||
mat2 modify1 = rotate(time * 0.02);
|
||||
f += amp * noise(p);
|
||||
p = modify1 * p * 2.0;
|
||||
amp *= 0.454545;
|
||||
|
||||
mat2 modify2 = rotate(time * 0.08);
|
||||
f += amp * noise(p);
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
float pattern(vec2 p, out vec2 q, out vec2 r) {
|
||||
vec2 offset1 = vec2(1.0);
|
||||
vec2 offset0 = vec2(0.0);
|
||||
mat2 rot01 = rotate(0.1 * time);
|
||||
mat2 rot1 = rotate(0.1);
|
||||
|
||||
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
|
||||
r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
|
||||
return fbm(p + r);
|
||||
}
|
||||
|
||||
float digit(vec2 p){
|
||||
vec2 grid = uGridMul * 15.0;
|
||||
vec2 s = floor(p * grid) / grid;
|
||||
p = p * grid;
|
||||
vec2 q, r;
|
||||
float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
|
||||
|
||||
if(uUseMouse > 0.5){
|
||||
vec2 mouseWorld = uMouse * uScale;
|
||||
float distToMouse = distance(s, mouseWorld);
|
||||
float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
|
||||
intensity += mouseInfluence;
|
||||
|
||||
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
|
||||
intensity += ripple;
|
||||
}
|
||||
|
||||
if(uUsePageLoadAnimation > 0.5){
|
||||
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
float cellDelay = cellRandom * 0.8;
|
||||
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
|
||||
|
||||
float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
|
||||
intensity *= fadeAlpha;
|
||||
}
|
||||
|
||||
p = fract(p);
|
||||
p *= uDigitSize;
|
||||
|
||||
float px5 = p.x * 5.0;
|
||||
float py5 = (1.0 - p.y) * 5.0;
|
||||
float x = fract(px5);
|
||||
float y = fract(py5);
|
||||
|
||||
float i = floor(py5) - 2.0;
|
||||
float j = floor(px5) - 2.0;
|
||||
float n = i * i + j * j;
|
||||
float f = n * 0.0625;
|
||||
|
||||
float isOn = step(0.1, intensity - f);
|
||||
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
|
||||
|
||||
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
|
||||
}
|
||||
|
||||
float onOff(float a, float b, float c)
|
||||
{
|
||||
return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount;
|
||||
}
|
||||
|
||||
float displace(vec2 look)
|
||||
{
|
||||
float y = look.y - mod(iTime * 0.25, 1.0);
|
||||
float window = 1.0 / (1.0 + 50.0 * y * y);
|
||||
return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
|
||||
}
|
||||
|
||||
vec3 getColor(vec2 p){
|
||||
|
||||
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
|
||||
bar *= uScanlineIntensity;
|
||||
|
||||
float displacement = displace(p);
|
||||
p.x += displacement;
|
||||
|
||||
if (uGlitchAmount != 1.0) {
|
||||
float extra = displacement * (uGlitchAmount - 1.0);
|
||||
p.x += extra;
|
||||
}
|
||||
|
||||
float middle = digit(p);
|
||||
|
||||
const float off = 0.002;
|
||||
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
|
||||
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
|
||||
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
|
||||
|
||||
vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
vec2 barrel(vec2 uv){
|
||||
vec2 c = uv * 2.0 - 1.0;
|
||||
float r2 = dot(c, c);
|
||||
c *= 1.0 + uCurvature * r2;
|
||||
return c * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
void main() {
|
||||
time = iTime * 0.333333;
|
||||
vec2 uv = vUv;
|
||||
|
||||
if(uCurvature != 0.0){
|
||||
uv = barrel(uv);
|
||||
}
|
||||
|
||||
vec2 p = uv * uScale;
|
||||
vec3 col = getColor(p);
|
||||
|
||||
if(uChromaticAberration != 0.0){
|
||||
vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
|
||||
col.r = getColor(p + ca).r;
|
||||
col.b = getColor(p - ca).b;
|
||||
}
|
||||
|
||||
col *= uTint;
|
||||
col *= uBrightness;
|
||||
|
||||
if(uDither > 0.0){
|
||||
float rnd = hash21(gl_FragCoord.xy);
|
||||
col += (rnd - 0.5) * (uDither * 0.003922);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
let h = hex.replace("#", "").trim();
|
||||
if (h.length === 3)
|
||||
h = h
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("");
|
||||
const num = parseInt(h, 16);
|
||||
return [
|
||||
((num >> 16) & 255) / 255,
|
||||
((num >> 8) & 255) / 255,
|
||||
(num & 255) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
export default function FaultyTerminal({
|
||||
scale = 1,
|
||||
gridMul = [2, 1],
|
||||
digitSize = 1.5,
|
||||
timeScale = 0.3,
|
||||
pause = false,
|
||||
scanlineIntensity = 0.3,
|
||||
glitchAmount = 1,
|
||||
flickerAmount = 1,
|
||||
noiseAmp = 1,
|
||||
chromaticAberration = 0,
|
||||
dither = 0,
|
||||
curvature = 0.2,
|
||||
tint = "#ffffff",
|
||||
mouseReact = true,
|
||||
mouseStrength = 0.2,
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2),
|
||||
pageLoadAnimation = true,
|
||||
brightness = 1,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
}: FaultyTerminalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const programRef = useRef<Program>(null);
|
||||
const rendererRef = useRef<Renderer>(null);
|
||||
const mouseRef = useRef({ x: 0.5, y: 0.5 });
|
||||
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
|
||||
const frozenTimeRef = useRef(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const loadAnimationStartRef = useRef<number>(0);
|
||||
const timeOffsetRef = useRef<number>(Math.random() * 100);
|
||||
|
||||
const tintVec = useMemo(() => hexToRgb(tint), [tint]);
|
||||
|
||||
const ditherValue = useMemo(
|
||||
() => (typeof dither === "boolean" ? (dither ? 1 : 0) : dither),
|
||||
[dither],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
const ctn = containerRef.current;
|
||||
if (!ctn) return;
|
||||
const rect = ctn.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1 - (e.clientY - rect.top) / rect.height;
|
||||
mouseRef.current = { x, y };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ctn = containerRef.current;
|
||||
if (!ctn) return;
|
||||
|
||||
const renderer = new Renderer({ dpr });
|
||||
rendererRef.current = renderer;
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height,
|
||||
),
|
||||
},
|
||||
uScale: { value: scale },
|
||||
|
||||
uGridMul: { value: new Float32Array(gridMul) },
|
||||
uDigitSize: { value: digitSize },
|
||||
uScanlineIntensity: { value: scanlineIntensity },
|
||||
uGlitchAmount: { value: glitchAmount },
|
||||
uFlickerAmount: { value: flickerAmount },
|
||||
uNoiseAmp: { value: noiseAmp },
|
||||
uChromaticAberration: { value: chromaticAberration },
|
||||
uDither: { value: ditherValue },
|
||||
uCurvature: { value: curvature },
|
||||
uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) },
|
||||
uMouse: {
|
||||
value: new Float32Array([
|
||||
smoothMouseRef.current.x,
|
||||
smoothMouseRef.current.y,
|
||||
]),
|
||||
},
|
||||
uMouseStrength: { value: mouseStrength },
|
||||
uUseMouse: { value: mouseReact ? 1 : 0 },
|
||||
uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 },
|
||||
uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 },
|
||||
uBrightness: { value: brightness },
|
||||
},
|
||||
});
|
||||
programRef.current = program;
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
function resize() {
|
||||
if (!ctn || !renderer) return;
|
||||
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
|
||||
program.uniforms.iResolution.value = new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height,
|
||||
);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
resizeObserver.observe(ctn);
|
||||
resize();
|
||||
|
||||
const update = (t: number) => {
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
|
||||
if (pageLoadAnimation && loadAnimationStartRef.current === 0) {
|
||||
loadAnimationStartRef.current = t;
|
||||
}
|
||||
|
||||
if (!pause) {
|
||||
const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale;
|
||||
program.uniforms.iTime.value = elapsed;
|
||||
frozenTimeRef.current = elapsed;
|
||||
} else {
|
||||
program.uniforms.iTime.value = frozenTimeRef.current;
|
||||
}
|
||||
|
||||
if (pageLoadAnimation && loadAnimationStartRef.current > 0) {
|
||||
const animationDuration = 2000;
|
||||
const animationElapsed = t - loadAnimationStartRef.current;
|
||||
const progress = Math.min(animationElapsed / animationDuration, 1);
|
||||
program.uniforms.uPageLoadProgress.value = progress;
|
||||
}
|
||||
|
||||
if (mouseReact) {
|
||||
const dampingFactor = 0.08;
|
||||
const smoothMouse = smoothMouseRef.current;
|
||||
const mouse = mouseRef.current;
|
||||
smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
|
||||
smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
|
||||
|
||||
const mouseUniform = program.uniforms.uMouse.value as Float32Array;
|
||||
mouseUniform[0] = smoothMouse.x;
|
||||
mouseUniform[1] = smoothMouse.y;
|
||||
}
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
ctn.appendChild(gl.canvas);
|
||||
|
||||
if (mouseReact) ctn.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
resizeObserver.disconnect();
|
||||
if (mouseReact) ctn.removeEventListener("mousemove", handleMouseMove);
|
||||
if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
|
||||
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
loadAnimationStartRef.current = 0;
|
||||
timeOffsetRef.current = Math.random() * 100;
|
||||
};
|
||||
}, [
|
||||
dpr,
|
||||
pause,
|
||||
timeScale,
|
||||
scale,
|
||||
gridMul,
|
||||
digitSize,
|
||||
scanlineIntensity,
|
||||
glitchAmount,
|
||||
flickerAmount,
|
||||
noiseAmp,
|
||||
chromaticAberration,
|
||||
ditherValue,
|
||||
curvature,
|
||||
tintVec,
|
||||
mouseReact,
|
||||
mouseStrength,
|
||||
pageLoadAnimation,
|
||||
brightness,
|
||||
handleMouseMove,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`w-full h-full relative overflow-hidden ${className}`}
|
||||
style={style}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type LogoItem =
|
||||
| {
|
||||
node: React.ReactNode;
|
||||
href?: string;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
| {
|
||||
src: string;
|
||||
alt?: string;
|
||||
href?: string;
|
||||
title?: string;
|
||||
srcSet?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export interface LogoLoopProps {
|
||||
logos: LogoItem[];
|
||||
speed?: number;
|
||||
direction?: "left" | "right";
|
||||
width?: number | string;
|
||||
logoHeight?: number;
|
||||
gap?: number;
|
||||
pauseOnHover?: boolean;
|
||||
fadeOut?: boolean;
|
||||
fadeOutColor?: string;
|
||||
scaleOnHover?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
SMOOTH_TAU: 0.25,
|
||||
MIN_COPIES: 2,
|
||||
COPY_HEADROOM: 2,
|
||||
} as const;
|
||||
|
||||
const toCssLength = (value?: number | string): string | undefined =>
|
||||
typeof value === "number" ? `${value}px` : (value ?? undefined);
|
||||
|
||||
const cx = (...parts: Array<string | false | null | undefined>) =>
|
||||
parts.filter(Boolean).join(" ");
|
||||
|
||||
const useResizeObserver = (
|
||||
callback: () => void,
|
||||
elements: Array<React.RefObject<Element | null>>,
|
||||
dependencies: React.DependencyList,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!window.ResizeObserver) {
|
||||
const handleResize = () => callback();
|
||||
window.addEventListener("resize", handleResize);
|
||||
callback();
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
|
||||
const observers = elements.map((ref) => {
|
||||
if (!ref.current) return null;
|
||||
const observer = new ResizeObserver(callback);
|
||||
observer.observe(ref.current);
|
||||
return observer;
|
||||
});
|
||||
|
||||
callback();
|
||||
|
||||
return () => {
|
||||
observers.forEach((observer) => observer?.disconnect());
|
||||
};
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
const useImageLoader = (
|
||||
seqRef: React.RefObject<HTMLUListElement | null>,
|
||||
onLoad: () => void,
|
||||
dependencies: React.DependencyList,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const images = seqRef.current?.querySelectorAll("img") ?? [];
|
||||
|
||||
if (images.length === 0) {
|
||||
onLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
let remainingImages = images.length;
|
||||
const handleImageLoad = () => {
|
||||
remainingImages -= 1;
|
||||
if (remainingImages === 0) {
|
||||
onLoad();
|
||||
}
|
||||
};
|
||||
|
||||
images.forEach((img) => {
|
||||
const htmlImg = img as HTMLImageElement;
|
||||
if (htmlImg.complete) {
|
||||
handleImageLoad();
|
||||
} else {
|
||||
htmlImg.addEventListener("load", handleImageLoad, { once: true });
|
||||
htmlImg.addEventListener("error", handleImageLoad, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
images.forEach((img) => {
|
||||
img.removeEventListener("load", handleImageLoad);
|
||||
img.removeEventListener("error", handleImageLoad);
|
||||
});
|
||||
};
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
const useAnimationLoop = (
|
||||
trackRef: React.RefObject<HTMLDivElement | null>,
|
||||
targetVelocity: number,
|
||||
seqWidth: number,
|
||||
isHovered: boolean,
|
||||
pauseOnHover: boolean,
|
||||
) => {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastTimestampRef = useRef<number | null>(null);
|
||||
const offsetRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const prefersReduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (seqWidth > 0) {
|
||||
offsetRef.current =
|
||||
((offsetRef.current % seqWidth) + seqWidth) % seqWidth;
|
||||
track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
|
||||
if (prefersReduced) {
|
||||
track.style.transform = "translate3d(0, 0, 0)";
|
||||
return () => {
|
||||
lastTimestampRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (lastTimestampRef.current === null) {
|
||||
lastTimestampRef.current = timestamp;
|
||||
}
|
||||
|
||||
const deltaTime =
|
||||
Math.max(0, timestamp - lastTimestampRef.current) / 1000;
|
||||
lastTimestampRef.current = timestamp;
|
||||
|
||||
const target = pauseOnHover && isHovered ? 0 : targetVelocity;
|
||||
|
||||
const easingFactor =
|
||||
1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
|
||||
velocityRef.current += (target - velocityRef.current) * easingFactor;
|
||||
|
||||
if (seqWidth > 0) {
|
||||
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
|
||||
nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;
|
||||
offsetRef.current = nextOffset;
|
||||
|
||||
const translateX = -offsetRef.current;
|
||||
track.style.transform = `translate3d(${translateX}px, 0, 0)`;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
lastTimestampRef.current = null;
|
||||
};
|
||||
}, [targetVelocity, seqWidth, isHovered, pauseOnHover]);
|
||||
};
|
||||
|
||||
export const LogoLoop = React.memo<LogoLoopProps>(
|
||||
({
|
||||
logos,
|
||||
speed = 120,
|
||||
direction = "left",
|
||||
width = "100%",
|
||||
logoHeight = 28,
|
||||
gap = 32,
|
||||
pauseOnHover = true,
|
||||
fadeOut = false,
|
||||
fadeOutColor,
|
||||
scaleOnHover = false,
|
||||
ariaLabel = "Partner logos",
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const seqRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const [seqWidth, setSeqWidth] = useState<number>(0);
|
||||
const [copyCount, setCopyCount] = useState<number>(
|
||||
ANIMATION_CONFIG.MIN_COPIES,
|
||||
);
|
||||
const [isHovered, setIsHovered] = useState<boolean>(false);
|
||||
|
||||
const targetVelocity = useMemo(() => {
|
||||
const magnitude = Math.abs(speed);
|
||||
const directionMultiplier = direction === "left" ? 1 : -1;
|
||||
const speedMultiplier = speed < 0 ? -1 : 1;
|
||||
return magnitude * directionMultiplier * speedMultiplier;
|
||||
}, [speed, direction]);
|
||||
|
||||
const updateDimensions = useCallback(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const sequenceWidth =
|
||||
seqRef.current?.getBoundingClientRect?.()?.width ?? 0;
|
||||
|
||||
if (sequenceWidth > 0) {
|
||||
setSeqWidth(Math.ceil(sequenceWidth));
|
||||
const copiesNeeded =
|
||||
Math.ceil(containerWidth / sequenceWidth) +
|
||||
ANIMATION_CONFIG.COPY_HEADROOM;
|
||||
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useResizeObserver(
|
||||
updateDimensions,
|
||||
[containerRef, seqRef],
|
||||
[logos, gap, logoHeight],
|
||||
);
|
||||
|
||||
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);
|
||||
|
||||
useAnimationLoop(
|
||||
trackRef,
|
||||
targetVelocity,
|
||||
seqWidth,
|
||||
isHovered,
|
||||
pauseOnHover,
|
||||
);
|
||||
|
||||
const cssVariables = useMemo(
|
||||
() =>
|
||||
({
|
||||
"--logoloop-gap": `${gap}px`,
|
||||
"--logoloop-logoHeight": `${logoHeight}px`,
|
||||
...(fadeOutColor && { "--logoloop-fadeColor": fadeOutColor }),
|
||||
}) as React.CSSProperties,
|
||||
[gap, logoHeight, fadeOutColor],
|
||||
);
|
||||
|
||||
const rootClasses = useMemo(
|
||||
() =>
|
||||
cx(
|
||||
"relative overflow-x-hidden group",
|
||||
"[--logoloop-gap:32px]",
|
||||
"[--logoloop-logoHeight:28px]",
|
||||
"[--logoloop-fadeColorAuto:#ffffff]",
|
||||
"dark:[--logoloop-fadeColorAuto:#0b0b0b]",
|
||||
scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]",
|
||||
className,
|
||||
),
|
||||
[scaleOnHover, className],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (pauseOnHover) setIsHovered(true);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (pauseOnHover) setIsHovered(false);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const renderLogoItem = useCallback(
|
||||
(item: LogoItem, key: React.Key) => {
|
||||
const isNodeItem = "node" in item;
|
||||
|
||||
const content = isNodeItem ? (
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex items-center",
|
||||
"motion-reduce:transition-none",
|
||||
scaleOnHover &&
|
||||
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
|
||||
)}
|
||||
aria-hidden={!!(item as any).href && !(item as any).ariaLabel}
|
||||
>
|
||||
{(item as any).node}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
className={cx(
|
||||
"h-[var(--logoloop-logoHeight)] w-auto block object-contain",
|
||||
"[-webkit-user-drag:none] pointer-events-none",
|
||||
"[image-rendering:-webkit-optimize-contrast]",
|
||||
"motion-reduce:transition-none",
|
||||
scaleOnHover &&
|
||||
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
|
||||
)}
|
||||
src={(item as any).src}
|
||||
srcSet={(item as any).srcSet}
|
||||
sizes={(item as any).sizes}
|
||||
width={(item as any).width}
|
||||
height={(item as any).height}
|
||||
alt={(item as any).alt ?? ""}
|
||||
title={(item as any).title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const itemAriaLabel = isNodeItem
|
||||
? ((item as any).ariaLabel ?? (item as any).title)
|
||||
: ((item as any).alt ?? (item as any).title);
|
||||
|
||||
const inner = (item as any).href ? (
|
||||
<a
|
||||
className={cx(
|
||||
"inline-flex items-center no-underline rounded",
|
||||
"transition-opacity duration-200 ease-linear",
|
||||
"hover:opacity-80",
|
||||
"focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2",
|
||||
)}
|
||||
href={(item as any).href}
|
||||
aria-label={itemAriaLabel || "logo link"}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cx(
|
||||
"flex-none mr-[var(--logoloop-gap)] text-[length:var(--logoloop-logoHeight)] leading-[1]",
|
||||
scaleOnHover && "overflow-visible group/item",
|
||||
)}
|
||||
key={key}
|
||||
role="listitem"
|
||||
>
|
||||
{inner}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
[scaleOnHover],
|
||||
);
|
||||
|
||||
const logoLists = useMemo(
|
||||
() =>
|
||||
Array.from({ length: copyCount }, (_, copyIndex) => (
|
||||
<ul
|
||||
className="flex items-center"
|
||||
key={`copy-${copyIndex}`}
|
||||
role="list"
|
||||
aria-hidden={copyIndex > 0}
|
||||
ref={copyIndex === 0 ? seqRef : undefined}
|
||||
>
|
||||
{logos.map((item, itemIndex) =>
|
||||
renderLogoItem(item, `${copyIndex}-${itemIndex}`),
|
||||
)}
|
||||
</ul>
|
||||
)),
|
||||
[copyCount, logos, renderLogoItem],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
(): React.CSSProperties => ({
|
||||
width: toCssLength(width) ?? "100%",
|
||||
...cssVariables,
|
||||
...style,
|
||||
}),
|
||||
[width, cssVariables, style],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={rootClasses}
|
||||
style={containerStyle}
|
||||
role="region"
|
||||
aria-label={ariaLabel}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{fadeOut && (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"pointer-events-none absolute inset-y-0 left-0 z-[1]",
|
||||
"w-[clamp(24px,8%,120px)]",
|
||||
"bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"pointer-events-none absolute inset-y-0 right-0 z-[1]",
|
||||
"w-[clamp(24px,8%,120px)]",
|
||||
"bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
"flex w-max will-change-transform select-none",
|
||||
"motion-reduce:transform-none",
|
||||
)}
|
||||
ref={trackRef}
|
||||
>
|
||||
{logoLists}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LogoLoop.displayName = "LogoLoop";
|
||||
|
||||
export default LogoLoop;
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RotatingTextProps {
|
||||
words: string[];
|
||||
className?: string;
|
||||
interval?: number; // Intervalo em milissegundos para a rotação
|
||||
}
|
||||
|
||||
export function RotatingText({
|
||||
words,
|
||||
className,
|
||||
interval = 3000,
|
||||
}: RotatingTextProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % words.length);
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [words, interval]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-block min-h-[1.5em]", className)}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="block text-center"
|
||||
>
|
||||
{words[currentIndex]}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
|
||||
|
||||
interface GridOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface SquaresProps {
|
||||
direction?: "diagonal" | "up" | "right" | "down" | "left";
|
||||
speed?: number;
|
||||
borderColor?: CanvasStrokeStyle;
|
||||
squareSize?: number;
|
||||
hoverFillColor?: CanvasStrokeStyle;
|
||||
}
|
||||
|
||||
const Squares: React.FC<SquaresProps> = ({
|
||||
direction = "right",
|
||||
speed = 1,
|
||||
borderColor = "#999",
|
||||
squareSize = 40,
|
||||
hoverFillColor = "#222",
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const requestRef = useRef<number | null>(null);
|
||||
const numSquaresX = useRef<number>(0);
|
||||
const numSquaresY = useRef<number>(0);
|
||||
const gridOffset = useRef<GridOffset>({ x: 0, y: 0 });
|
||||
const hoveredSquareRef = useRef<GridOffset | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
numSquaresX.current = Math.ceil(canvas.width / squareSize) + 1;
|
||||
numSquaresY.current = Math.ceil(canvas.height / squareSize) + 1;
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
|
||||
const drawGrid = () => {
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const startX = Math.floor(gridOffset.current.x / squareSize) * squareSize;
|
||||
const startY = Math.floor(gridOffset.current.y / squareSize) * squareSize;
|
||||
|
||||
for (let x = startX; x < canvas.width + squareSize; x += squareSize) {
|
||||
for (let y = startY; y < canvas.height + squareSize; y += squareSize) {
|
||||
const squareX = x - (gridOffset.current.x % squareSize);
|
||||
const squareY = y - (gridOffset.current.y % squareSize);
|
||||
|
||||
if (
|
||||
hoveredSquareRef.current &&
|
||||
hoveredSquareRef.current.x >= squareX &&
|
||||
hoveredSquareRef.current.x < squareX + squareSize &&
|
||||
hoveredSquareRef.current.y >= squareY &&
|
||||
hoveredSquareRef.current.y < squareY + squareSize
|
||||
) {
|
||||
ctx.fillStyle = hoverFillColor;
|
||||
ctx.fillRect(squareX, squareY, squareSize, squareSize);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.strokeRect(squareX, squareY, squareSize, squareSize);
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
0,
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2,
|
||||
);
|
||||
gradient.addColorStop(0, "rgba(0, 0, 0, 0)");
|
||||
gradient.addColorStop(1, "#060010");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const updateAnimation = () => {
|
||||
const effectiveSpeed = Math.max(speed, 0.1);
|
||||
switch (direction) {
|
||||
case "right":
|
||||
gridOffset.current.x =
|
||||
(gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
|
||||
break;
|
||||
case "left":
|
||||
gridOffset.current.x =
|
||||
(gridOffset.current.x + effectiveSpeed + squareSize) % squareSize;
|
||||
break;
|
||||
case "up":
|
||||
gridOffset.current.y =
|
||||
(gridOffset.current.y + effectiveSpeed + squareSize) % squareSize;
|
||||
break;
|
||||
case "down":
|
||||
gridOffset.current.y =
|
||||
(gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
|
||||
break;
|
||||
case "diagonal":
|
||||
gridOffset.current.x =
|
||||
(gridOffset.current.x - effectiveSpeed + squareSize) % squareSize;
|
||||
gridOffset.current.y =
|
||||
(gridOffset.current.y - effectiveSpeed + squareSize) % squareSize;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
drawGrid();
|
||||
requestRef.current = requestAnimationFrame(updateAnimation);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
hoveredSquareRef.current = { x: mouseX, y: mouseY };
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hoveredSquareRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseleave", handleMouseLeave); // Use document for leaving window
|
||||
requestRef.current = requestAnimationFrame(updateAnimation);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeCanvas);
|
||||
if (requestRef.current) cancelAnimationFrame(requestRef.current);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||
};
|
||||
}, [direction, speed, borderColor, hoverFillColor, squareSize]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full border-none block max-w-full"
|
||||
></canvas>
|
||||
);
|
||||
};
|
||||
|
||||
export default Squares;
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { gsap } from "gsap";
|
||||
import {
|
||||
createElement,
|
||||
type ElementType,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface TextTypeProps {
|
||||
className?: string;
|
||||
showCursor?: boolean;
|
||||
hideCursorWhileTyping?: boolean;
|
||||
cursorCharacter?: string | React.ReactNode;
|
||||
cursorBlinkDuration?: number;
|
||||
cursorClassName?: string;
|
||||
text: string | string[];
|
||||
as?: ElementType;
|
||||
typingSpeed?: number;
|
||||
initialDelay?: number;
|
||||
pauseDuration?: number;
|
||||
deletingSpeed?: number;
|
||||
loop?: boolean;
|
||||
textColors?: string[];
|
||||
variableSpeed?: { min: number; max: number };
|
||||
onSentenceComplete?: (sentence: string, index: number) => void;
|
||||
startOnVisible?: boolean;
|
||||
reverseMode?: boolean;
|
||||
}
|
||||
|
||||
const TextType = ({
|
||||
text,
|
||||
as: Component = "div",
|
||||
typingSpeed = 50,
|
||||
initialDelay = 0,
|
||||
pauseDuration = 2000,
|
||||
deletingSpeed = 30,
|
||||
loop = true,
|
||||
className = "",
|
||||
showCursor = true,
|
||||
hideCursorWhileTyping = false,
|
||||
cursorCharacter = "|",
|
||||
cursorClassName = "",
|
||||
cursorBlinkDuration = 0.5,
|
||||
textColors = [],
|
||||
variableSpeed,
|
||||
onSentenceComplete,
|
||||
startOnVisible = false,
|
||||
reverseMode = false,
|
||||
...props
|
||||
}: TextTypeProps & React.HTMLAttributes<HTMLElement>) => {
|
||||
const [displayedText, setDisplayedText] = useState("");
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [currentTextIndex, setCurrentTextIndex] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(!startOnVisible);
|
||||
const cursorRef = useRef<HTMLSpanElement>(null);
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
|
||||
const textArray = useMemo(
|
||||
() => (Array.isArray(text) ? text : [text]),
|
||||
[text],
|
||||
);
|
||||
|
||||
const getRandomSpeed = useCallback(() => {
|
||||
if (!variableSpeed) return typingSpeed;
|
||||
const { min, max } = variableSpeed;
|
||||
return Math.random() * (max - min) + min;
|
||||
}, [variableSpeed, typingSpeed]);
|
||||
|
||||
const getCurrentTextColor = () => {
|
||||
if (textColors.length === 0) return "#ffffff";
|
||||
return textColors[currentTextIndex % textColors.length];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!startOnVisible || !containerRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [startOnVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCursor && cursorRef.current) {
|
||||
gsap.set(cursorRef.current, { opacity: 1 });
|
||||
gsap.to(cursorRef.current, {
|
||||
opacity: 0,
|
||||
duration: cursorBlinkDuration,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "power2.inOut",
|
||||
});
|
||||
}
|
||||
}, [showCursor, cursorBlinkDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const currentText = textArray[currentTextIndex];
|
||||
const processedText = reverseMode
|
||||
? currentText.split("").reverse().join("")
|
||||
: currentText;
|
||||
|
||||
const executeTypingAnimation = () => {
|
||||
if (isDeleting) {
|
||||
if (displayedText === "") {
|
||||
setIsDeleting(false);
|
||||
if (currentTextIndex === textArray.length - 1 && !loop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSentenceComplete) {
|
||||
onSentenceComplete(textArray[currentTextIndex], currentTextIndex);
|
||||
}
|
||||
|
||||
setCurrentTextIndex((prev) => (prev + 1) % textArray.length);
|
||||
setCurrentCharIndex(0);
|
||||
timeout = setTimeout(() => {}, pauseDuration);
|
||||
} else {
|
||||
timeout = setTimeout(() => {
|
||||
setDisplayedText((prev) => prev.slice(0, -1));
|
||||
}, deletingSpeed);
|
||||
}
|
||||
} else {
|
||||
if (currentCharIndex < processedText.length) {
|
||||
timeout = setTimeout(
|
||||
() => {
|
||||
setDisplayedText(
|
||||
(prev) => prev + processedText[currentCharIndex],
|
||||
);
|
||||
setCurrentCharIndex((prev) => prev + 1);
|
||||
},
|
||||
variableSpeed ? getRandomSpeed() : typingSpeed,
|
||||
);
|
||||
} else if (textArray.length > 1) {
|
||||
timeout = setTimeout(() => {
|
||||
setIsDeleting(true);
|
||||
}, pauseDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentCharIndex === 0 && !isDeleting && displayedText === "") {
|
||||
timeout = setTimeout(executeTypingAnimation, initialDelay);
|
||||
} else {
|
||||
executeTypingAnimation();
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [
|
||||
currentCharIndex,
|
||||
displayedText,
|
||||
isDeleting,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseDuration,
|
||||
textArray,
|
||||
currentTextIndex,
|
||||
loop,
|
||||
initialDelay,
|
||||
isVisible,
|
||||
reverseMode,
|
||||
variableSpeed,
|
||||
onSentenceComplete,
|
||||
]);
|
||||
|
||||
const shouldHideCursor =
|
||||
hideCursorWhileTyping &&
|
||||
(currentCharIndex < textArray[currentTextIndex].length || isDeleting);
|
||||
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
ref: containerRef,
|
||||
className: `inline-block whitespace-pre-wrap tracking-tight ${className}`,
|
||||
...props,
|
||||
},
|
||||
<span className="inline" style={{ color: getCurrentTextColor() }}>
|
||||
{displayedText}
|
||||
</span>,
|
||||
showCursor && (
|
||||
<span
|
||||
ref={cursorRef}
|
||||
className={`ml-1 inline-block opacity-100 ${shouldHideCursor ? "hidden" : ""} ${cursorClassName}`}
|
||||
>
|
||||
{cursorCharacter}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export default TextType;
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedGradientTextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
}: AnimatedGradientTextProps) {
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
"bg-gradient-to-r from-cyan-400 via-blue-500 to-blue-600 bg-clip-text text-transparent font-bold",
|
||||
className,
|
||||
)}
|
||||
animate={{
|
||||
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{
|
||||
backgroundSize: "200% 200%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuroraTextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuroraText({ children, className }: AuroraTextProps) {
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
"bg-gradient-to-r from-blue-400 via-cyan-500 to-blue-500 bg-clip-text text-transparent",
|
||||
className,
|
||||
)}
|
||||
animate={{
|
||||
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
style={{
|
||||
backgroundSize: "200% 200%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BentoGridProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface BentoGridItemProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
}
|
||||
|
||||
export function BentoGrid({ children, className }: BentoGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-6xl mx-auto justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BentoGridItem({
|
||||
children,
|
||||
className,
|
||||
colSpan = 1,
|
||||
rowSpan = 1,
|
||||
}: BentoGridItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg border border-white/20 bg-white/5 backdrop-blur-sm p-6",
|
||||
`col-span-${colSpan} row-span-${rowSpan}`,
|
||||
className,
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-blue-400/10 to-cyan-500/10 opacity-0 hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="relative z-10">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import {
|
||||
type MotionValue,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import React, { type PropsWithChildren, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DockProps extends VariantProps<typeof dockVariants> {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
iconMagnification?: number;
|
||||
disableMagnification?: boolean;
|
||||
iconDistance?: number;
|
||||
direction?: "top" | "middle" | "bottom";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_SIZE = 40;
|
||||
const DEFAULT_MAGNIFICATION = 60;
|
||||
const DEFAULT_DISTANCE = 140;
|
||||
const DEFAULT_DISABLEMAGNIFICATION = false;
|
||||
|
||||
const dockVariants = cva(
|
||||
"supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto mt-8 flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md",
|
||||
);
|
||||
|
||||
const Dock = React.forwardRef<HTMLDivElement, DockProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
iconSize = DEFAULT_SIZE,
|
||||
iconMagnification = DEFAULT_MAGNIFICATION,
|
||||
disableMagnification = DEFAULT_DISABLEMAGNIFICATION,
|
||||
iconDistance = DEFAULT_DISTANCE,
|
||||
direction = "middle",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const mouseX = useMotionValue(Infinity);
|
||||
|
||||
const renderChildren = () => {
|
||||
return React.Children.map(children, (child) => {
|
||||
if (
|
||||
React.isValidElement<DockIconProps>(child) &&
|
||||
child.type === DockIcon
|
||||
) {
|
||||
return React.cloneElement(child, {
|
||||
...child.props,
|
||||
mouseX: mouseX,
|
||||
size: iconSize,
|
||||
magnification: iconMagnification,
|
||||
disableMagnification: disableMagnification,
|
||||
distance: iconDistance,
|
||||
});
|
||||
}
|
||||
return child;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
onMouseMove={(e) => mouseX.set(e.pageX)}
|
||||
onMouseLeave={() => mouseX.set(Infinity)}
|
||||
{...props}
|
||||
className={cn(dockVariants({ className }), {
|
||||
"items-start": direction === "top",
|
||||
"items-center": direction === "middle",
|
||||
"items-end": direction === "bottom",
|
||||
})}
|
||||
>
|
||||
{renderChildren()}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Dock.displayName = "Dock";
|
||||
|
||||
export interface DockIconProps
|
||||
extends Omit<MotionProps & React.HTMLAttributes<HTMLDivElement>, "children"> {
|
||||
size?: number;
|
||||
magnification?: number;
|
||||
disableMagnification?: boolean;
|
||||
distance?: number;
|
||||
mouseX?: MotionValue<number>;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
props?: PropsWithChildren;
|
||||
}
|
||||
|
||||
const DockIcon = ({
|
||||
size = DEFAULT_SIZE,
|
||||
magnification = DEFAULT_MAGNIFICATION,
|
||||
disableMagnification,
|
||||
distance = DEFAULT_DISTANCE,
|
||||
mouseX,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DockIconProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const padding = Math.max(6, size * 0.2);
|
||||
const defaultMouseX = useMotionValue(Infinity);
|
||||
|
||||
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
|
||||
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
|
||||
return val - bounds.x - bounds.width / 2;
|
||||
});
|
||||
|
||||
const targetSize = disableMagnification ? size : magnification;
|
||||
|
||||
const sizeTransform = useTransform(
|
||||
distanceCalc,
|
||||
[-distance, 0, distance],
|
||||
[size, targetSize, size],
|
||||
);
|
||||
|
||||
const scaleSize = useSpring(sizeTransform, {
|
||||
mass: 0.1,
|
||||
stiffness: 150,
|
||||
damping: 12,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{ width: scaleSize, height: scaleSize, padding }}
|
||||
className={cn(
|
||||
"flex aspect-square cursor-pointer items-center justify-center rounded-full",
|
||||
disableMagnification && "transition-colors hover:bg-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
DockIcon.displayName = "DockIcon";
|
||||
|
||||
export { Dock, DockIcon, dockVariants };
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* DotPattern Component Props
|
||||
*
|
||||
* @param {number} [width=16] - The horizontal spacing between dots
|
||||
* @param {number} [height=16] - The vertical spacing between dots
|
||||
* @param {number} [x=0] - The x-offset of the entire pattern
|
||||
* @param {number} [y=0] - The y-offset of the entire pattern
|
||||
* @param {number} [cx=1] - The x-offset of individual dots
|
||||
* @param {number} [cy=1] - The y-offset of individual dots
|
||||
* @param {number} [cr=1] - The radius of each dot
|
||||
* @param {string} [className] - Additional CSS classes to apply to the SVG container
|
||||
* @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
|
||||
*/
|
||||
interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
cr?: number;
|
||||
className?: string;
|
||||
glow?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* DotPattern Component
|
||||
*
|
||||
* A React component that creates an animated or static dot pattern background using SVG.
|
||||
* The pattern automatically adjusts to fill its container and can optionally display glowing dots.
|
||||
*
|
||||
* @component
|
||||
*
|
||||
* @see DotPatternProps for the props interface.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <DotPattern />
|
||||
*
|
||||
* // With glowing effect and custom spacing
|
||||
* <DotPattern
|
||||
* width={20}
|
||||
* height={20}
|
||||
* glow={true}
|
||||
* className="opacity-50"
|
||||
* />
|
||||
*
|
||||
* @notes
|
||||
* - The component is client-side only ("use client")
|
||||
* - Automatically responds to container size changes
|
||||
* - When glow is enabled, dots will animate with random delays and durations
|
||||
* - Uses Motion for animations
|
||||
* - Dots color can be controlled via the text color utility classes
|
||||
*/
|
||||
|
||||
export function DotPattern({
|
||||
width = 16,
|
||||
height = 16,
|
||||
x = 0,
|
||||
y = 0,
|
||||
cx = 1,
|
||||
cy = 1,
|
||||
cr = 1,
|
||||
className,
|
||||
glow = false,
|
||||
...props
|
||||
}: DotPatternProps) {
|
||||
const id = useId();
|
||||
const containerRef = useRef<SVGSVGElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
window.addEventListener("resize", updateDimensions);
|
||||
return () => window.removeEventListener("resize", updateDimensions);
|
||||
}, []);
|
||||
|
||||
const dots = Array.from(
|
||||
{
|
||||
length:
|
||||
Math.ceil(dimensions.width / width) *
|
||||
Math.ceil(dimensions.height / height),
|
||||
},
|
||||
(_, i) => {
|
||||
const col = i % Math.ceil(dimensions.width / width);
|
||||
const row = Math.floor(i / Math.ceil(dimensions.width / width));
|
||||
return {
|
||||
x: col * width + cx,
|
||||
y: row * height + cy,
|
||||
delay: Math.random() * 5,
|
||||
duration: Math.random() * 3 + 2,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id={`${id}-gradient`}>
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{dots.map((dot, index) => (
|
||||
<motion.circle
|
||||
key={`${dot.x}-${dot.y}`}
|
||||
cx={dot.x}
|
||||
cy={dot.y}
|
||||
r={cr}
|
||||
fill={glow ? `url(#${id}-gradient)` : "currentColor"}
|
||||
className="text-neutral-400/80"
|
||||
initial={glow ? { opacity: 0.4, scale: 1 } : {}}
|
||||
animate={
|
||||
glow
|
||||
? {
|
||||
opacity: [0.4, 1, 0.4],
|
||||
scale: [1, 1.5, 1],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={
|
||||
glow
|
||||
? {
|
||||
duration: dot.duration,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse",
|
||||
delay: dot.delay,
|
||||
ease: "easeInOut",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MOVEMENT_DAMPING = 1400;
|
||||
|
||||
const GLOBE_CONFIG: COBEOptions = {
|
||||
width: 800,
|
||||
height: 800,
|
||||
onRender: () => {},
|
||||
devicePixelRatio: 2,
|
||||
phi: 0,
|
||||
theta: 0.3,
|
||||
dark: 0,
|
||||
diffuse: 0.4,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 1.2,
|
||||
baseColor: [1, 1, 1],
|
||||
markerColor: [251 / 255, 100 / 255, 21 / 255],
|
||||
glowColor: [1, 1, 1],
|
||||
markers: [
|
||||
{ location: [14.5995, 120.9842], size: 0.03 },
|
||||
{ location: [19.076, 72.8777], size: 0.1 },
|
||||
{ location: [23.8103, 90.4125], size: 0.05 },
|
||||
{ location: [30.0444, 31.2357], size: 0.07 },
|
||||
{ location: [39.9042, 116.4074], size: 0.08 },
|
||||
{ location: [-23.5505, -46.6333], size: 0.1 },
|
||||
{ location: [19.4326, -99.1332], size: 0.1 },
|
||||
{ location: [40.7128, -74.006], size: 0.1 },
|
||||
{ location: [34.6937, 135.5022], size: 0.05 },
|
||||
{ location: [41.0082, 28.9784], size: 0.06 },
|
||||
],
|
||||
};
|
||||
|
||||
export function Globe({
|
||||
className,
|
||||
config = GLOBE_CONFIG,
|
||||
}: {
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
}) {
|
||||
let phi = 0;
|
||||
let width = 0;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerInteractionMovement = useRef(0);
|
||||
|
||||
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) => {
|
||||
if (pointerInteracting.current !== null) {
|
||||
const delta = clientX - pointerInteracting.current;
|
||||
pointerInteractionMovement.current = delta;
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
width = canvasRef.current.offsetWidth;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
|
||||
const globe = createGlobe(canvasRef.current!, {
|
||||
...config,
|
||||
width: width * 2,
|
||||
height: width * 2,
|
||||
onRender: (state) => {
|
||||
if (!pointerInteracting.current) phi += 0.005;
|
||||
state.phi = phi + rs.get();
|
||||
state.width = width * 2;
|
||||
state.height = width * 2;
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => (canvasRef.current!.style.opacity = "1"), 0);
|
||||
return () => {
|
||||
globe.destroy();
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [rs, config]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 mx-auto aspect-[1/1] w-full max-w-[600px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
className={cn(
|
||||
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]",
|
||||
)}
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
pointerInteracting.current = e.clientX;
|
||||
updatePointerInteraction(e.clientX);
|
||||
}}
|
||||
onPointerUp={() => updatePointerInteraction(null)}
|
||||
onPointerOut={() => updatePointerInteraction(null)}
|
||||
onMouseMove={(e) => updateMovement(e.clientX)}
|
||||
onTouchMove={(e) =>
|
||||
e.touches[0] && updateMovement(e.touches[0].clientX)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MagicCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MagicCard({ children, className }: MagicCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg border border-white/20 bg-white/5 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-shimmer" />
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShimmerButtonProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ShimmerButton({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: ShimmerButtonProps) {
|
||||
return (
|
||||
<motion.button
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg bg-gradient-to-r from-cyan-500 to-blue-600 px-6 py-3 font-semibold text-white shadow-lg",
|
||||
className,
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer" />
|
||||
<span className="relative z-10">{children}</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextRevealProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TextReveal({ children, className }: TextRevealProps) {
|
||||
return (
|
||||
<motion.p
|
||||
className={cn("text-gray-300 leading-relaxed", className)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{children}
|
||||
</motion.p>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Verificação automática de projetos na VPS
|
||||
|
||||
A lista pública de projetos só exibe demos cujo snapshot atual tenha resposta final HTTP `200` e `Content-Type: text/html`. O snapshot vence após 15 minutos; se não houver uma verificação atual, nenhum projeto é exibido.
|
||||
|
||||
## Pré-requisito
|
||||
|
||||
O cron deve executar no mesmo diretório de release que contém `data/project-health.json` consumido pelo processo Next.js. Em deploys com diretório versionado, aponte o cron para o symlink `current`.
|
||||
|
||||
```cron
|
||||
*/10 * * * * cd /var/www/evandro.dev.br/current && /usr/bin/node scripts/check-projects.mjs >> /var/log/evandro-project-health.log 2>&1
|
||||
```
|
||||
|
||||
Para validar manualmente:
|
||||
|
||||
```bash
|
||||
cd /var/www/evandro.dev.br/current
|
||||
npm run check:projects
|
||||
cat data/project-health.json
|
||||
```
|
||||
|
||||
O script segue redirecionamentos, limita cada requisição a 10 segundos, não aceita `3xx`, `4xx`, `5xx`, DNS/TLS inválido nem conteúdo não HTML. Ele grava em arquivo temporário e renomeia ao final, evitando que o Next.js leia JSON parcial.
|
||||
|
||||
Após a primeira instalação, rode `npm run check:projects` antes de iniciar a aplicação. O ISR da home é de 5 minutos; portanto, uma alteração de disponibilidade pode levar até esse intervalo adicional para aparecer no HTML público.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Disponibilidade das demos de projetos
|
||||
|
||||
A homepage seleciona projetos pelo campo `featured` de `public/portfolio-data.json`. O snapshot de health nunca decide se um card existe; ele serve somente para apresentar o estado do CTA da demo.
|
||||
|
||||
## Estados
|
||||
|
||||
`lib/project-health.ts` deriva quatro estados:
|
||||
|
||||
| Estado | Condição | Comportamento do CTA |
|
||||
| --- | --- | --- |
|
||||
| `available` | A demo tem URL e a última resposta conhecida foi `200` com `Content-Type: text/html`. | Exibe “Abrir projeto”. |
|
||||
| `unavailable` | A demo tem URL e a última resposta conhecida foi conclusiva, mas não atende ao contrato `200` + HTML. | Exibe “Demo indisponível na última verificação” sem link. |
|
||||
| `unknown` | A demo tem URL, mas não existe observação ou a tentativa terminou em erro/status nulo. | Mantém “Abrir projeto” com aviso acessível de disponibilidade não verificada. |
|
||||
| `none` | O projeto não tem `demoUrl`. | Não exibe CTA de demo. |
|
||||
|
||||
Links de código e os demais dados do card independem desses estados.
|
||||
|
||||
## Leitura durante a renderização
|
||||
|
||||
`loadFeaturedProjects()` lê `data/project-health.json` quando a homepage é renderizada.
|
||||
|
||||
- **Produção:** usa a última observação disponível, mesmo antiga. Não faz fetch externo durante render e continua exibindo todos os projetos `featured` se o arquivo estiver ausente ou inválido.
|
||||
- **Desenvolvimento:** usa imediatamente um snapshot com até 15 minutos. Se o snapshot estiver ausente ou antigo, consulta as demos em memória. Uma falha conserva a última observação conclusiva daquele projeto quando ela existe; caso contrário, o estado fica `unknown`. Esse refresh não grava o arquivo.
|
||||
|
||||
## Atualização manual opcional
|
||||
|
||||
Para atualizar a observação versionada:
|
||||
|
||||
```bash
|
||||
pnpm check:projects
|
||||
```
|
||||
|
||||
`scripts/check-projects.mjs` lê as demos de `public/portfolio-data.json`, segue redirecionamentos, aplica timeout de 10 segundos e registra status, content type, URL final, latência, horário e eventual erro. A escrita é atômica: o processo grava `data/project-health.json.<pid>.tmp` e só então renomeia o arquivo para `data/project-health.json`.
|
||||
|
||||
O comando consulta serviços externos e altera `data/project-health.json`; execute-o apenas quando uma nova observação deva ser revisada e versionada.
|
||||
|
||||
## Papel do snapshot
|
||||
|
||||
`data/project-health.json` é a última observação conhecida, não um gate de build, deploy ou conteúdo. Ele pode estar antigo, ausente ou conter falhas sem esconder os cards selecionados. Nenhum cron é necessário para a aplicação funcionar; uma automação externa pode executar a atualização manual, mas não faz parte deste repositório nem da renderização de produção.
|
||||
@@ -0,0 +1,903 @@
|
||||
# Portfolio Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task in the current checkout. Do not dispatch writing agents: one editor owns the dirty working tree. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Tornar conteúdo, acessibilidade, movimento, dependências, analytics, deploy e validação do portfólio previsíveis, com menos JavaScript e sem alterar sua identidade visual.
|
||||
|
||||
**Architecture:** Conteúdo de projetos continua estático e recebe apenas uma projeção observacional de saúde de demo; falha de monitoramento nunca remove card. O shell deixa de instalar scroll/animação global, mantendo Motion local e GSAP somente na história do notebook. Dependências exatas, lock congelado, consentimento opt-in e CI formam o limite operacional.
|
||||
|
||||
**Tech Stack:** Next.js 16.3.0 App Router, React 19.1.0, TypeScript 5.9.2, Tailwind CSS 4.1.12, Motion 12.23.12, GSAP 3.13.0, React Three Fiber 9.5.0, Jest 30.3.0, Biome 2.2.0, Node 22 e pnpm 9.15.9.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-04-portfolio-hardening-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Trabalhar no checkout atual; não criar worktree porque mudanças não commitadas são parte do estado-alvo e haverá um único editor.
|
||||
- Não executar branch, commit, reset, checkout, stash nem comando Git destrutivo.
|
||||
- Preservar `data/project-health.json` e o `globals.css` não rastreado da raiz sem qualquer edição.
|
||||
- Evoluir, sem restaurar, o conteúdo atual de `lib/portfolio-data.ts` e `lib/portfolio-data.load.test.ts`.
|
||||
- Editar somente `app/globals.css`; nunca importar, mover ou apagar o `globals.css` da raiz.
|
||||
- Manter preto/papel/verde ácido, fontes, hero command-center, globo, starfield, notebook, cartões e conteúdo factual existente.
|
||||
- Não criar dados, métricas, clientes, reviews, endereços, preços ou claims.
|
||||
- Não alterar metadata, JSON-LD, sitemap, robots ou conteúdo por SEO/GEO externo; essa integração terá plano separado.
|
||||
- Não adicionar dependência; remover apenas as dependências que ficarem sem consumidores.
|
||||
- Não usar suppressions do Biome, audit force, ignore de advisory, override transitivo, alias ou shim.
|
||||
- Não adicionar Playwright/Cypress; o smoke browser usa o Chromium do harness.
|
||||
- Antes de editar símbolo exportado, consultar referências via LSP; antes de excluir arquivo, repetir busca de importadores.
|
||||
- Cada checkpoint deve ficar verde antes do grupo seguinte.
|
||||
|
||||
## File Map
|
||||
|
||||
### Conteúdo e saúde de demo
|
||||
|
||||
- Modify: `lib/project-health.ts` — classificar a última observação sem filtrar conteúdo.
|
||||
- Modify: `lib/project-health.test.ts` — contrato de disponibilidade.
|
||||
- Modify: `lib/portfolio-data.ts` — projetar todo featured com estado de demo e limitar refresh a desenvolvimento.
|
||||
- Modify: `lib/portfolio-data.test.ts` — cards persistem em estados de saúde distintos.
|
||||
- Modify: `lib/portfolio-data.load.test.ts` — produção sem rede; desenvolvimento atualiza sem esvaziar.
|
||||
- Modify: `app/page.tsx` — usar o novo loader e remover ISR de monitoramento.
|
||||
- Modify: `app/components/Projects.tsx` — CTA disponível, desconhecido e indisponível.
|
||||
- Create: `app/components/Projects.test.tsx` — comportamento acessível do CTA.
|
||||
|
||||
### Shell, movimento e performance
|
||||
|
||||
- Modify: `app/layout.tsx` — remover wrappers globais; manter metadata/JSON-LD intactos.
|
||||
- Modify: `app/components/cinematic/CinematicOverlays.tsx` — manter somente overlays leves da home.
|
||||
- Delete: `app/components/cinematic/PageIntro.tsx`, `app/components/cinematic/PageIntro.test.tsx`.
|
||||
- Delete: `app/providers/SmoothScrollProvider.tsx`.
|
||||
- Delete: `app/providers/AnimationProvider.tsx`, `app/providers/AnimationProvider.test.tsx`.
|
||||
- Delete: `app/lib/animations/config.ts`, `app/lib/animations/variants.ts`.
|
||||
- Delete: `app/components/RouteTransition.tsx`.
|
||||
- Modify: `app/globals.css` — camada base de links, reduced motion, grain/marquee/divisor CSS e tokens consolidados.
|
||||
- Modify: `app/components/cinematic/CinematicHome.tsx` — retirar diretores/wrappers redundantes.
|
||||
- Modify: `app/components/cinematic/CinematicHome.test.tsx` — testar ordem de conteúdo, não mocks removidos.
|
||||
- Delete: `app/components/cinematic/CinematicSection.tsx`.
|
||||
- Delete: `app/components/story/ReelDirector.tsx`.
|
||||
- Delete: `app/hooks/useScrollReveal.ts`.
|
||||
- Rewrite: `app/components/cinematic/FilmGrain.tsx` — elemento decorativo sem canvas/RAF.
|
||||
- Rewrite: `app/components/cinematic/ScrollVelocityText.tsx` — marquee CSS sem hooks por frame.
|
||||
- Rewrite: `app/components/cinematic/SectionDivider.tsx` — DOM/SVG estático e CSS.
|
||||
- Delete: `app/components/cinematic/CustomCursor.tsx`.
|
||||
- Modify: `app/components/cinematic/ControlRoomGlobe.tsx` — interseção e visibilidade separadas.
|
||||
- Modify: `app/components/story/NotebookStory.tsx` — stage pausado em aba oculta.
|
||||
- Modify: `app/components/story/NotebookStory.test.tsx` — transição de visibilidade.
|
||||
- Modify: `app/components/Hero.tsx` — única variante ativa.
|
||||
- Modify: `app/components/Hero.test.tsx`, `app/components/Hero.reducedMotion.test.tsx` — somente comportamento observável.
|
||||
- Modify: `app/components/cinematic/HeroScene.tsx` — remover variante ignorada.
|
||||
|
||||
### Código morto e testes sem valor
|
||||
|
||||
- Delete: `app/components/ContactForm.tsx`, `app/components/ContactInfo.tsx`, `lib/usePortfolioData.ts`.
|
||||
- Delete: `app/components/CompanyLogos.tsx` e seus três testes.
|
||||
- Delete: `components/DotGrid.tsx`, `components/TextType.tsx`, `components/FaultyTerminal.tsx`, `components/Squares.tsx`, `components/LogoLoop.tsx`, `components/RotatingText.tsx`.
|
||||
- Delete: todos os nove arquivos em `components/magicui/` e `components.json`.
|
||||
- Delete: `app/components/cinematic/FloatingElements.tsx`, `HomeButton.tsx`, `HorizontalScroll.tsx`, `CinematicText.tsx`.
|
||||
- Delete: os três PBTs do Footer e os três PBTs de Experience.
|
||||
- Create: `app/components/Experience.test.tsx` — filtro featured e ordem.
|
||||
- Modify: `app/components/Footer.test.tsx` — remover asserção de classes internas.
|
||||
|
||||
### Dependências, segurança, CI e documentação
|
||||
|
||||
- Modify: `package.json`, `pnpm-lock.yaml`, `tailwind.config.js`, `nixpacks.toml`, `jest.setup.ts`.
|
||||
- Create: `.github/workflows/ci.yml`.
|
||||
- Modify: `app/components/GoogleAnalytics.tsx`.
|
||||
- Create: `app/components/GoogleAnalytics.test.tsx`.
|
||||
- Modify: `next.config.ts`.
|
||||
- Modify: `README.md`.
|
||||
- Move/replace: `docs/project-health-cron.md` → `docs/project-health.md`.
|
||||
|
||||
## Sequência e dependências
|
||||
|
||||
```text
|
||||
1 saúde pura
|
||||
└─ 2 loader
|
||||
└─ 3 apresentação de projetos
|
||||
|
||||
4 shell/foco
|
||||
└─ 5 home sem GSAP redundante
|
||||
└─ 6 efeitos sem loops
|
||||
└─ 7 pause de renderers
|
||||
|
||||
8 hero única
|
||||
9 código morto e testes
|
||||
└─ 10 dependências/lock/deploy
|
||||
└─ 11 analytics/headers
|
||||
└─ 12 CI/docs/verificação final
|
||||
```
|
||||
|
||||
## Tabela de conflitos de arquivos e interfaces
|
||||
|
||||
| Tarefas | Arquivo/interface compartilhada | Tipo de conflito | Regra de serialização |
|
||||
| --- | --- | --- | --- |
|
||||
| 1, 2 | `ProjectHealthSnapshot`, classificação de demo | Tipo e semântica | Concluir 1 antes de alterar loader |
|
||||
| 2, 3 | `PortfolioProjectView`, `loadFeaturedProjects()` | Producer/consumer | Fixar assinatura em 2; migrar UI em 3 |
|
||||
| 3, 5 | props `projects` de `CinematicHome`/`NotebookStory` | Tipo por interseção | Provar compatibilidade em 3 antes de simplificar home |
|
||||
| 4, 6, 11 | `app/layout.tsx`, `CinematicOverlays` | Shell e ordem de scripts | Executar 4 → 6 → 11; reler antes de cada edição |
|
||||
| 4, 6 | `app/globals.css` | Base, reduced motion e efeitos | Aplicar base/reduced em 4; efeitos/tokens em 6 |
|
||||
| 5, 6 | `CinematicHome.tsx` | Composição e componentes substituídos | Remover wrappers em 5; trocar efeitos em 6 |
|
||||
| 6, 7 | política de loops/visibilidade | Contrato de execução | Eliminar loops evitáveis antes de instrumentar os restantes |
|
||||
| 4, 8 | `Hero` sob shell sem wrappers | Hidratação e teste visual | Estabilizar shell antes de podar hero |
|
||||
| 5, 9, 10 | consumidores de GSAP/dependências | Grafo de imports | Apagar consumidores em 5/9; remover pacotes somente em 10 |
|
||||
| 9, 10 | `fast-check`, CVA, OGL, Lenis e plugins Tailwind | Último consumidor | Confirmar zero importadores em 9; podar manifesto em 10 |
|
||||
| 10, 11 | `package.json`, lock, `next.config.ts` | Next patch e build | Congelar toolchain antes de validar analytics/headers |
|
||||
| 4, 11 | `app/layout.tsx` e `GoogleAnalytics` | Montagem global | Layout limpo em 4; consentimento em 11 sem reintroduzir wrapper |
|
||||
| 10, 12 | scripts, lock e CI | Comandos contratuais | Workflow somente após comandos locais verdes |
|
||||
| 2, 12 | docs de project health | Semântica pública | Documentar apenas o comportamento já comprovado |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Trocar filtro de projetos por classificação de demo
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/project-health.ts`
|
||||
- Modify: `lib/project-health.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProjectHealthCheck | undefined`.
|
||||
- Produces:
|
||||
|
||||
```ts
|
||||
export type DemoAvailability = "available" | "unavailable" | "unknown" | "none";
|
||||
|
||||
export function getDemoAvailability(
|
||||
demoUrl: string | null | undefined,
|
||||
check: ProjectHealthCheck | undefined,
|
||||
): DemoAvailability;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Substituir testes de filtragem por testes de classificação**
|
||||
|
||||
Criar casos explícitos no teste existente:
|
||||
|
||||
```ts
|
||||
expect(getDemoAvailability(null, undefined)).toBe("none");
|
||||
expect(getDemoAvailability("https://demo.example", undefined)).toBe("unknown");
|
||||
expect(getDemoAvailability("https://demo.example", snapshot.projects.online)).toBe("available");
|
||||
expect(getDemoAvailability("https://demo.example", snapshot.projects.redirect)).toBe("unavailable");
|
||||
expect(getDemoAvailability("https://demo.example", snapshot.projects.stale)).toBe("available");
|
||||
```
|
||||
|
||||
A última expectativa registra que expiração governa refresh em desenvolvimento, não a existência/estado da última observação em produção.
|
||||
|
||||
- [ ] **Step 2: Executar o teste vermelho**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec jest lib/project-health.test.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: falha porque `getDemoAvailability` ainda não existe ou o filtro antigo retorna lista vazia.
|
||||
|
||||
- [ ] **Step 3: Implementar a função pura e remover `filterLiveProjects`/`isCurrentLiveCheck`**
|
||||
|
||||
Implementação mínima:
|
||||
|
||||
```ts
|
||||
export function getDemoAvailability(
|
||||
demoUrl: string | null | undefined,
|
||||
check: ProjectHealthCheck | undefined,
|
||||
): DemoAvailability {
|
||||
if (!demoUrl) return "none";
|
||||
if (!check) return "unknown";
|
||||
return check.status === 200 &&
|
||||
check.contentType?.toLowerCase().startsWith("text/html")
|
||||
? "available"
|
||||
: "unavailable";
|
||||
}
|
||||
```
|
||||
|
||||
Manter `PROJECT_HEALTH_MAX_AGE_MS` e `createProjectHealthSnapshot`; expor um helper de frescor somente para o loader de desenvolvimento.
|
||||
|
||||
- [ ] **Step 4: Confirmar o contrato verde**
|
||||
|
||||
Run o mesmo Jest. Expected: `PASS`; nenhuma chamada de rede neste teste.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fazer o loader sempre retornar projetos editoriais
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/portfolio-data.ts`
|
||||
- Modify: `lib/portfolio-data.test.ts`
|
||||
- Modify: `lib/portfolio-data.load.test.ts`
|
||||
- Modify: `app/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getDemoAvailability`, `ProjectHealthSnapshot`, `PROJECT_HEALTH_MAX_AGE_MS`.
|
||||
- Produces:
|
||||
|
||||
```ts
|
||||
export type PortfolioProjectView = PortfolioProject & {
|
||||
demoAvailability: DemoAvailability;
|
||||
demoCheckedAt: string | null;
|
||||
};
|
||||
|
||||
export function getFeaturedProjects(
|
||||
snapshot: ProjectHealthSnapshot | null,
|
||||
): PortfolioProjectView[];
|
||||
|
||||
export async function loadFeaturedProjects(): Promise<PortfolioProjectView[]>;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Atualizar o teste puro**
|
||||
|
||||
Fixture deve provar três invariantes em uma única lista: featured permanece, `featured: false` sai, e estado de demo não filtra card.
|
||||
|
||||
```ts
|
||||
const result = getFeaturedProjects(snapshot);
|
||||
expect(result.map(({ id }) => id)).toEqual(
|
||||
portfolio.projects.filter(({ featured }) => featured !== false).map(({ id }) => id),
|
||||
);
|
||||
expect(result.find(({ id }) => id === "sev-modaintima")?.demoAvailability).toBe("available");
|
||||
expect(result.find(({ id }) => id === "twins-lab")?.demoAvailability).toBe("unknown");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Atualizar o teste do loader sem apagar o baseline do usuário**
|
||||
|
||||
Produção:
|
||||
|
||||
```ts
|
||||
await expect(loadFeaturedProjects()).resolves.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "sev-modaintima" })]),
|
||||
);
|
||||
expect(createProjectHealthSnapshot).not.toHaveBeenCalled();
|
||||
```
|
||||
|
||||
Desenvolvimento com snapshot antigo:
|
||||
|
||||
```ts
|
||||
expect(createProjectHealthSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "sev-modaintima", demoAvailability: "available" }),
|
||||
]));
|
||||
```
|
||||
|
||||
Adicionar falha de refresh: a promise rejeitada não rejeita o loader nem esvazia a lista.
|
||||
|
||||
- [ ] **Step 3: Rodar ambos os testes e observar a quebra do contrato antigo**
|
||||
|
||||
```bash
|
||||
pnpm exec jest lib/project-health.test.ts lib/portfolio-data.test.ts lib/portfolio-data.load.test.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: falha nas expectativas “sempre retorna featured”.
|
||||
|
||||
- [ ] **Step 4: Implementar projeção e loader**
|
||||
|
||||
`getFeaturedProjects` faz um único `filter(featured !== false)` e um `map`; `loadFeaturedProjects` lê snapshot uma vez. Em produção retorna a projeção imediatamente. Em desenvolvimento, atualiza somente se snapshot ausente/antigo, envolve o refresh em `try/catch` e usa snapshot anterior em falha.
|
||||
|
||||
- [ ] **Step 5: Migrar a home**
|
||||
|
||||
Em `app/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<CinematicHome
|
||||
projects={await loadFeaturedProjects()}
|
||||
experiences={portfolio.experiences}
|
||||
skillCategories={portfolio.skills.technical}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Remover `revalidate = 300`; não adicionar outro cron/cache para saúde.
|
||||
|
||||
- [ ] **Step 6: Checkpoint do loader**
|
||||
|
||||
Run o comando do passo 3. Expected: todos passam; mock de fetch não é chamado em produção; rejeição dev ainda retorna cards.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Apresentar disponibilidade sem remover evidência
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/Projects.tsx`
|
||||
- Create: `app/components/Projects.test.tsx`
|
||||
- Modify: `app/components/story/notebookProjects.ts`
|
||||
- Modify: `app/components/story/notebookProjects.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PortfolioProjectView[]`.
|
||||
- Produces: card sempre visível e CTA de demo acessível por estado.
|
||||
|
||||
- [ ] **Step 1: Escrever testes de UI por papel e nome acessível**
|
||||
|
||||
Cobrir três cards:
|
||||
|
||||
```tsx
|
||||
expect(screen.getByRole("heading", { name: "Demo disponível" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://available.example",
|
||||
);
|
||||
expect(screen.getByText("Demo indisponível na última verificação")).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Abrir projeto.*disponibilidade não verificada/i }))
|
||||
.toHaveAttribute("href", "https://unknown.example");
|
||||
```
|
||||
|
||||
O teste não inspeciona classes, props de motion ou contagem de wrappers.
|
||||
|
||||
- [ ] **Step 2: Rodar teste vermelho**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/Projects.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Expected: falha porque `Projects` ainda trata qualquer `demoUrl` como navegável e não anota estado.
|
||||
|
||||
- [ ] **Step 3: Implementar CTA por estado**
|
||||
|
||||
- `available`: link atual.
|
||||
- `unavailable`: `span` visualmente equivalente, `aria-disabled="true"`, sem `href`, `target` ou click.
|
||||
- `unknown`: link configurado, com texto auxiliar acessível “Disponibilidade não verificada”.
|
||||
- `none`: sem CTA.
|
||||
|
||||
Não esconder `article`, título, descrição, stack, GitHub ou status.
|
||||
|
||||
- [ ] **Step 4: Adaptar o seletor do notebook sem torná-lo dependente de saúde**
|
||||
|
||||
`selectNotebookProjects` continua usando screenshot local + `demoUrl`; aceita `PortfolioProjectView[]` estruturalmente e não filtra por `demoAvailability`.
|
||||
|
||||
- [ ] **Step 5: Checkpoint da apresentação**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/Projects.test.tsx app/components/story/notebookProjects.test.ts lib/portfolio-data.test.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: todos passam e o fixture indisponível continua presente.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remover bloqueadores globais e corrigir foco/reduced motion
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/layout.tsx`
|
||||
- Modify: `app/components/cinematic/CinematicOverlays.tsx`
|
||||
- Modify: `app/globals.css`
|
||||
- Delete: `app/components/cinematic/PageIntro.tsx`
|
||||
- Delete: `app/components/cinematic/PageIntro.test.tsx`
|
||||
- Delete: `app/providers/SmoothScrollProvider.tsx`
|
||||
- Delete: `app/providers/AnimationProvider.tsx`
|
||||
- Delete: `app/providers/AnimationProvider.test.tsx`
|
||||
- Delete: `app/lib/animations/config.ts`
|
||||
- Delete: `app/lib/animations/variants.ts`
|
||||
- Delete: `app/components/RouteTransition.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: layout server-first sem splash, Lenis, contexto ou transição global.
|
||||
|
||||
- [ ] **Step 1: Repetir referências e remover `PageIntro` do overlay**
|
||||
|
||||
`CinematicOverlays` não importa nem renderiza o portal. Excluir componente e teste no mesmo passo.
|
||||
|
||||
- [ ] **Step 2: Achatar o body do layout**
|
||||
|
||||
Preservar JSON-LD, skip link, Header, overlays e analytics na ordem abaixo:
|
||||
|
||||
```tsx
|
||||
<body>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">Pular para o conteúdo</a>
|
||||
<Header />
|
||||
<CinematicOverlays />
|
||||
<div id="main-content" tabIndex={-1}>{children}</div>
|
||||
<GoogleAnalytics />
|
||||
</body>
|
||||
```
|
||||
|
||||
Não tocar no objeto `metadata` nem no grafo JSON-LD.
|
||||
|
||||
- [ ] **Step 3: Excluir infraestrutura sem consumidores**
|
||||
|
||||
Apagar os providers, config/variants e `RouteTransition`; repetir busca pelos nomes e exigir zero resultado em código vivo.
|
||||
|
||||
- [ ] **Step 4: Corrigir cascata de links**
|
||||
|
||||
Mover `a { color: inherit; }` para dentro de `@layer base`, junto do reset, para as utilities da 404 prevalecerem.
|
||||
|
||||
- [ ] **Step 5: Adicionar fallback reduced motion global**
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Não usar `display: none` para conteúdo.
|
||||
|
||||
- [ ] **Step 6: Checkpoint de shell no browser**
|
||||
|
||||
Iniciar a app pelo processo supervisionado, abrir `/`, focar o primeiro controle com `Tab` e confirmar que o skip link aparece imediatamente e move foco para `#main-content`. Abrir uma rota inexistente e confirmar visualmente: CTA primário branco sobre azul; CTA secundário slate e hover branco. Emular reduced motion e confirmar conteúdo completo sem splash/smooth scroll. Console sem erro de provider/hidratação.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Retirar GSAP redundante da home
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/cinematic/CinematicHome.tsx`
|
||||
- Modify: `app/components/cinematic/CinematicHome.test.tsx`
|
||||
- Delete: `app/components/cinematic/CinematicSection.tsx`
|
||||
- Delete: `app/components/story/ReelDirector.tsx`
|
||||
- Delete: `app/hooks/useScrollReveal.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: mesma ordem sem wrapper de revelação ou scanner global.
|
||||
|
||||
- [ ] **Step 1: Atualizar teste de integração**
|
||||
|
||||
Manter somente o contrato útil: exatamente uma história de notebook e `About` depois dela. Remover mocks/asserções de `ReelDirector` e `CinematicSection`.
|
||||
|
||||
- [ ] **Step 2: Simplificar composição**
|
||||
|
||||
Remover `ReelDirector` e renderizar `About`, `Skills`, `Experience`, `Projects`, `LighthouseMetrics`, `WorkModalities` e `Contact` diretamente. Os próprios componentes mantêm Motion/reduced motion.
|
||||
|
||||
- [ ] **Step 3: Excluir os três módulos sem consumidores**
|
||||
|
||||
Apagar `CinematicSection`, `ReelDirector` e `useScrollReveal`. GSAP deve permanecer importado somente por `NotebookStory` e nenhum caminho morto.
|
||||
|
||||
- [ ] **Step 4: Checkpoint de ordem**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/cinematic/CinematicHome.test.tsx app/components/story/NotebookStory.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Expected: `PASS`; home mantém hero → notebook → about → demais seções.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Trocar efeitos contínuos evitáveis por CSS/SVG
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `app/components/cinematic/FilmGrain.tsx`
|
||||
- Rewrite: `app/components/cinematic/ScrollVelocityText.tsx`
|
||||
- Rewrite: `app/components/cinematic/SectionDivider.tsx`
|
||||
- Modify: `app/components/cinematic/CinematicOverlays.tsx`
|
||||
- Modify: `app/components/cinematic/CinematicHome.tsx`
|
||||
- Modify: `app/globals.css`
|
||||
- Delete: `app/components/cinematic/CustomCursor.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- `ScrollVelocityText` passa a consumir `direction?: "left" | "right"` no lugar de `baseVelocity`.
|
||||
- `SectionDivider` mantém `variant` para não espalhar branches no caller.
|
||||
|
||||
- [ ] **Step 1: Remover cursor customizado**
|
||||
|
||||
Excluir import/render dinâmico e o arquivo. O cursor nativo volta a operar; retirar qualquer regra global `cursor: none` junto com o componente.
|
||||
|
||||
- [ ] **Step 2: Fazer grain sem cliente/RAF**
|
||||
|
||||
`FilmGrain` retorna somente:
|
||||
|
||||
```tsx
|
||||
export default function FilmGrain() {
|
||||
return <div aria-hidden="true" className="film-grain" />;
|
||||
}
|
||||
```
|
||||
|
||||
CSS usa textura estática/repeating gradients, `pointer-events: none`, posição fixa e a opacidade atual. Reduced motion/coarse pointer pode ocultar apenas esse ornamento.
|
||||
|
||||
- [ ] **Step 3: Fazer marquee em CSS**
|
||||
|
||||
Remover `useAnimationFrame`, `useScroll`, springs, resize state e Motion. Renderizar cópias suficientes em dois trilhos sem estado; `direction` seleciona `animate-marquee` ou `animate-marquee-reverse`. Migrar três callers de `baseVelocity` para direção explícita.
|
||||
|
||||
- [ ] **Step 4: Fazer divisor sem canvas**
|
||||
|
||||
- `gradient`: duas linhas CSS; animação existente.
|
||||
- `pulse`: cinco pontos CSS.
|
||||
- `waveform`: SVG estático com três paths, `aria-hidden="true"`.
|
||||
|
||||
Nenhum branch chama `requestAnimationFrame`.
|
||||
|
||||
- [ ] **Step 5: Consolidar tokens tocados**
|
||||
|
||||
Usar `--slash: #ccff00`, `--ink` e `--paper` como fonte única nas novas regras; remover apenas keyframes/classes que ficaram sem consumidores. Não reformatar o stylesheet nem copiar o arquivo da raiz.
|
||||
|
||||
- [ ] **Step 6: Checkpoint visual/performance**
|
||||
|
||||
No browser, verificar desktop e mobile: marquee legível, divisores presentes, grain sutil, cursor nativo, nenhum layout shift. Com Performance/console, confirmar ausência de RAF originado em `FilmGrain`, `ScrollVelocityText` e `SectionDivider`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Pausar renderers restantes fora de uso
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/story/NotebookStory.tsx`
|
||||
- Modify: `app/components/story/NotebookStory.test.tsx`
|
||||
- Modify: `app/components/cinematic/ControlRoomGlobe.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `isVisible = sectionIntersects && !document.hidden` para o Canvas do notebook.
|
||||
- Globo mantém `intersectsRef` separado de `document.hidden`.
|
||||
|
||||
- [ ] **Step 1: Escrever teste de Page Visibility para notebook**
|
||||
|
||||
Capturar a prop `isVisible` do mock dinâmico. Após `Object.defineProperty(document, "hidden", { value: true, configurable: true })` e `visibilitychange`, esperar `false`; ao voltar a `false`, o valor depende novamente da interseção.
|
||||
|
||||
- [ ] **Step 2: Implementar estado combinado no notebook**
|
||||
|
||||
Não duplicar timeline. Adicionar listener `visibilitychange`, limpar no unmount e passar a conjunção ao `NotebookStage3D`.
|
||||
|
||||
- [ ] **Step 3: Corrigir o globo**
|
||||
|
||||
O callback do Intersection Observer altera apenas `intersectsRef`. O `onRender` retorna cedo quando `!intersectsRef.current || document.hidden`; `visibilitychange` não sobrescreve estado de interseção.
|
||||
|
||||
- [ ] **Step 4: Checkpoint de pausa**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/story/NotebookStory.test.tsx app/components/story/NotebookStage3D.test.tsx --runInBand
|
||||
```
|
||||
|
||||
No browser, rolar notebook/globo para fora, ocultar/mostrar aba e verificar que não há salto nem erro WebGL ao retornar.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Reduzir Hero à variante ativa
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/Hero.tsx`
|
||||
- Modify: `app/components/Hero.test.tsx`
|
||||
- Modify: `app/components/Hero.reducedMotion.test.tsx`
|
||||
- Modify: `app/components/cinematic/HeroScene.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Hero sem prop/seleção de variante; conteúdo command-center é constante.
|
||||
|
||||
- [ ] **Step 1: Ajustar testes para o contrato visível**
|
||||
|
||||
Remover spy de `Math.random`, mock/asserção de `variant` e inspeção `data-initial`/`data-animate`. Manter heading, CTA WhatsApp, link de cases, CV, links sociais e ausência de 3D em reduced motion.
|
||||
|
||||
- [ ] **Step 2: Podar o modelo de variantes**
|
||||
|
||||
Trocar `HERO_VARIANTS[0]` por `HERO_CONTENT` sem `weight`, `scene` e `layout`. Remover cinco objetos inalcançáveis, `LAYOUT_CLASSES`, layouts alternativos e todos os branches de `SideVisual`; manter diretamente starfield, globo e telemetria.
|
||||
|
||||
- [ ] **Step 3: Simplificar `HeroScene`**
|
||||
|
||||
Remover união `HeroSceneVariant`, prop `variant` e `void variant`; caller passa somente `isVisible`.
|
||||
|
||||
- [ ] **Step 4: Checkpoint da hero**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/Hero.test.tsx app/components/Hero.reducedMotion.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Expected: conteúdo e ações passam; nenhum teste depende de branch morto.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Excluir código morto e podar testes de implementação
|
||||
|
||||
**Files:**
|
||||
- Delete: grupos listados em “Código morto e testes sem valor”.
|
||||
- Create: `app/components/Experience.test.tsx`.
|
||||
- Modify: `app/components/Footer.test.tsx`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Contact` continua como formulário ativo; `Footer` e `Experience` mantêm APIs atuais.
|
||||
- Produces: zero importadores órfãos e testes comportamentais pequenos.
|
||||
|
||||
- [ ] **Step 1: Excluir caminho antigo de contato**
|
||||
|
||||
Confirmar que somente os próprios arquivos referenciam `ContactForm`, `ContactInfo` e `usePortfolioData`; apagar os três. Não alterar `Contact.tsx`.
|
||||
|
||||
- [ ] **Step 2: Excluir CompanyLogos e testes**
|
||||
|
||||
Apagar componente, `CompanyLogos.test.tsx`, `CompanyLogos.altText.pbt.test.tsx` e `CompanyLogos.reducedMotion.pbt.test.tsx`. Não apagar logos genéricos usados por `Logo`.
|
||||
|
||||
- [ ] **Step 3: Excluir biblioteca demo/magicui**
|
||||
|
||||
Apagar os seis componentes da raiz de `components/`, os nove de `components/magicui/` e `components.json`. Confirmar que `@/lib/utils` continua consumido por `ControlRoomGlobe` e `TelemetryStrip`.
|
||||
|
||||
- [ ] **Step 4: Excluir cinemáticos sem importadores**
|
||||
|
||||
Apagar `FloatingElements`, `HomeButton`, `HorizontalScroll` e `CinematicText`.
|
||||
|
||||
- [ ] **Step 5: Substituir PBTs por dois testes de Experience**
|
||||
|
||||
Excluir os três `Experience.*.pbt.test.tsx`. Novo teste usa fixture fixa e verifica:
|
||||
|
||||
```ts
|
||||
expect(screen.queryByRole("heading", { name: "Oculta" })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("article").map((article) =>
|
||||
within(article).getByRole("heading", { level: 3 }).textContent,
|
||||
)).toEqual(["Mais recente", "Mais antiga"]);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Remover PBTs redundantes do Footer**
|
||||
|
||||
Excluir os três arquivos `Footer.*.pbt.test.tsx`. Em `Footer.test.tsx`, manter contato, navegação, nomes acessíveis, `target`/`rel` e ano; remover somente teste que fixa `.ds-grid-2`, quantidade de cards e classes.
|
||||
|
||||
- [ ] **Step 7: Checkpoint de corte**
|
||||
|
||||
```bash
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm exec jest app/components/Experience.test.tsx app/components/Footer.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Expected: TypeScript sem import quebrado; testes comportamentais passam.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Fixar dependências, runtime e deploy
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Modify: `pnpm-lock.yaml`
|
||||
- Modify: `tailwind.config.js`
|
||||
- Modify: `nixpacks.toml`
|
||||
- Modify: `jest.setup.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Node `22.x`, pnpm `9.15.9`, Next `16.3.0`, scripts `typecheck` e versões exatas.
|
||||
|
||||
- [ ] **Step 1: Fixar manifesto**
|
||||
|
||||
Adicionar:
|
||||
|
||||
```json
|
||||
"engines": { "node": "22.x" },
|
||||
"packageManager": "pnpm@9.15.9"
|
||||
```
|
||||
|
||||
Adicionar script:
|
||||
|
||||
```json
|
||||
"typecheck": "tsc --noEmit"
|
||||
```
|
||||
|
||||
Fixar às versões resolvidas do spec; mover `@types/three` para dev e `@types/node` para `22.20.1`.
|
||||
|
||||
- [ ] **Step 2: Remover dependências sem consumidores**
|
||||
|
||||
Remover do manifesto: `@gsap/react`, `class-variance-authority`, `lenis`, `ogl`, `tailwindcss-animate`, `tw-animate-css`, `fast-check`, `@types/resize-observer-browser`, `resize-observer-polyfill`.
|
||||
|
||||
Manter `@react-three/drei`, `@react-three/fiber`, `three`, `cobe`, `gsap`, `motion`, `clsx`, `tailwind-merge`, Jest e Testing Library.
|
||||
|
||||
- [ ] **Step 3: Retirar plugin e mock órfãos**
|
||||
|
||||
Em `tailwind.config.js`, deixar `plugins: []`. Em `jest.setup.ts`, remover import de `./__mocks__/resizeObserver` somente se o mock nativo definido no arquivo cobre todos os testes; apagar `__mocks__/resizeObserver.js` se ficar sem importador.
|
||||
|
||||
- [ ] **Step 4: Regenerar lock com a versão fixada**
|
||||
|
||||
Ativar pnpm 9.15.9 via Corepack e executar:
|
||||
|
||||
```bash
|
||||
corepack prepare pnpm@9.15.9 --activate
|
||||
pnpm install --lockfile-only
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Expected: `pnpm-lock.yaml` sem seção `overrides`; Next resolve `16.3.0`; manifesto e lock concordam.
|
||||
|
||||
- [ ] **Step 5: Corrigir cadeias auditadas sem force/override**
|
||||
|
||||
Executar as auditorias separadas. Se uma cadeia direta ainda resolver versão vulnerável, atualizar o pacote direto que a introduz para a menor versão compatível corrigida, fixar essa versão e repetir os três comandos do passo 4.
|
||||
|
||||
```bash
|
||||
pnpm audit --prod --audit-level=high
|
||||
pnpm audit --dev --audit-level=high
|
||||
```
|
||||
|
||||
Expected: ambos código zero. É proibido usar `--force`, ignore ou override para obter verde.
|
||||
|
||||
- [ ] **Step 6: Congelar Nixpacks**
|
||||
|
||||
Manter `nodejs_22` e `pnpm-9_x`; trocar somente install por:
|
||||
|
||||
```toml
|
||||
[phases.install]
|
||||
cmds = ["pnpm install --frozen-lockfile"]
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Checkpoint de toolchain**
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Expected: instalação não altera lock, TypeScript e Biome encerram em zero. Corrigir diagnósticos nos arquivos responsáveis; não alterar `biome.json` para desligar regras.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Implementar consentimento e corrigir headers/cache
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/GoogleAnalytics.tsx`
|
||||
- Create: `app/components/GoogleAnalytics.test.tsx`
|
||||
- Modify: `next.config.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Storage key: `evandro.analytics-consent.v1`.
|
||||
- Values persistidos: `granted | denied`.
|
||||
- GA ID permanece `G-QTT9QT0T3S`.
|
||||
|
||||
- [ ] **Step 1: Escrever testes de consentimento**
|
||||
|
||||
Testes devem provar:
|
||||
|
||||
```ts
|
||||
expect(screen.queryByTestId("google-analytics-script")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Aceitar analytics" })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
|
||||
expect(localStorage.getItem("evandro.analytics-consent.v1")).toBe("granted");
|
||||
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
|
||||
"src",
|
||||
"https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S",
|
||||
);
|
||||
```
|
||||
|
||||
Adicionar recusa sem script e revogação que grava `denied`, define `ga-disable-G-QTT9QT0T3S` e expira cookies `_ga`/`_ga_*`.
|
||||
|
||||
- [ ] **Step 2: Rodar teste vermelho**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/GoogleAnalytics.test.tsx --runInBand
|
||||
```
|
||||
|
||||
Expected: falha porque loader atual monta GA sem decisão e não oferece controles.
|
||||
|
||||
- [ ] **Step 3: Implementar UI e loader no arquivo existente**
|
||||
|
||||
Inicializar estado após mount lendo somente a chave versionada. Sem decisão, renderizar um diálogo não modal acessível com descrição curta e aceitar/recusar. Após escolha, manter botão de preferências. Montar `next/script` direto apenas em `granted`; advertising consent fica sempre denied.
|
||||
|
||||
- [ ] **Step 4: Implementar revogação completa alcançável**
|
||||
|
||||
Ao revogar:
|
||||
|
||||
```ts
|
||||
window["ga-disable-G-QTT9QT0T3S"] = true;
|
||||
window.gtag?.("consent", "update", {
|
||||
analytics_storage: "denied",
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
ad_personalization: "denied",
|
||||
});
|
||||
```
|
||||
|
||||
Expirar somente cookies cujos nomes começam com `_ga`, para path `/` e domínio atual quando aplicável. Não apagar storage/cookies alheios.
|
||||
|
||||
- [ ] **Step 5: Remover rewrites e cache incorreto**
|
||||
|
||||
Excluir `rewrites()` inteiro de `next.config.ts`. Remover headers customizados `immutable` de `/static`, `/fonts`, `/assets`, `/logos`.
|
||||
|
||||
- [ ] **Step 6: Ajustar CSP com risco controlado**
|
||||
|
||||
Manter `Content-Security-Policy-Report-Only`. Incluir `default-src 'self'` e `form-action 'self'`; construir `script-src` com `unsafe-eval` somente em desenvolvimento. Manter `unsafe-inline` e origens oficiais do Google. Não criar nonce/proxy nesta rodada.
|
||||
|
||||
- [ ] **Step 7: Checkpoint automatizado e browser**
|
||||
|
||||
```bash
|
||||
pnpm exec jest app/components/GoogleAnalytics.test.tsx --runInBand
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
No browser de produção local: limpar a chave/cookies; confirmar zero request Google antes de escolher; recusar e recarregar, ainda zero; aceitar e observar um único carregamento direto; revogar e confirmar flag/consent denied e cookies acessíveis removidos. Conferir console sem violações que quebrem a app.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Criar CI, atualizar documentação e fechar a iniciativa
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/ci.yml`
|
||||
- Modify: `README.md`
|
||||
- Move/replace: `docs/project-health-cron.md` → `docs/project-health.md`
|
||||
|
||||
**Interfaces:**
|
||||
- CI usa scripts reais de `package.json` e lock final.
|
||||
- Docs descrevem somente o comportamento verificado nas tarefas 1–11.
|
||||
|
||||
- [ ] **Step 1: Criar workflow único**
|
||||
|
||||
Conteúdo operacional:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test -- --runInBand
|
||||
- run: pnpm build
|
||||
- run: pnpm audit --prod --audit-level=high
|
||||
- run: pnpm audit --dev --audit-level=high
|
||||
```
|
||||
|
||||
`pnpm/action-setup` lê `packageManager`; não duplicar versão no workflow.
|
||||
|
||||
- [ ] **Step 2: Reescrever README com fatos do repositório**
|
||||
|
||||
Atualizar badge/stack para Next 16.3.0; remover Lenis, demos apagados e PBTs; registrar Node 22, pnpm 9.15.9, quick start, todos os scripts existentes, App Router, conteúdo estático, notebook 3D progressivo, consentimento, CI e Nixpacks frozen. Não criar seção de resultados comerciais nem promessas de performance não medidas.
|
||||
|
||||
- [ ] **Step 3: Substituir documentação de cron**
|
||||
|
||||
Remover `docs/project-health-cron.md` e criar `docs/project-health.md` com:
|
||||
|
||||
- cards derivados de `featured`, nunca de saúde;
|
||||
- estados `available`, `unavailable`, `unknown`, `none`;
|
||||
- produção sem fetch externo durante render;
|
||||
- atualização dev em memória quando snapshot está antigo;
|
||||
- `pnpm check:projects` como atualização manual atômica opcional;
|
||||
- `data/project-health.json` como última observação versionada, não requisito de conteúdo.
|
||||
|
||||
Não editar o snapshot ao documentá-lo.
|
||||
|
||||
- [ ] **Step 4: Rodar gate completo uma única vez**
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test -- --runInBand
|
||||
pnpm build
|
||||
pnpm audit --prod --audit-level=high
|
||||
pnpm audit --dev --audit-level=high
|
||||
```
|
||||
|
||||
Expected: todos encerram com código zero; install não modifica lock; build lista as rotas atuais.
|
||||
|
||||
- [ ] **Step 5: Smoke browser final**
|
||||
|
||||
Exercitar `/`, `/cases`, `/servicos`, `/curriculo` e uma 404 em desktop e viewport móvel. Confirmar projetos, foco, menu, notebook/fallback, formulário/WhatsApp, reduced motion, consentimento e ausência de erro de console. Não instalar runner de browser.
|
||||
|
||||
- [ ] **Step 6: Conferir arquivos protegidos e corte limpo**
|
||||
|
||||
Por leitura/comparação com snapshots iniciais, confirmar:
|
||||
|
||||
- `data/project-health.json` inalterado;
|
||||
- `globals.css` da raiz inalterado;
|
||||
- alterações em `lib/portfolio-data.ts` e seu teste preservam o refresh dev existente enquanto removem fail-closed;
|
||||
- nenhum import aponta para arquivo excluído;
|
||||
- nenhum artifact temporário, log, relatório ou cache foi criado no repo.
|
||||
|
||||
## Auto-revisão de cobertura do plano
|
||||
|
||||
| Requisito aprovado | Cobertura |
|
||||
| --- | --- |
|
||||
| Cards sempre visíveis; health não remove; dev atualiza; prod sem cron | Tasks 1–3 e 12 |
|
||||
| Next 16.3.0, advisories, sem force/overrides, Node 22, pnpm 9, frozen lock | Task 10 |
|
||||
| Remover PageIntro, corrigir 404, reduced motion, pausar loops | Tasks 4, 6 e 7 |
|
||||
| Código morto, Hero única, PBTs, deps órfãs, Biome verde | Tasks 5, 8, 9 e 10 |
|
||||
| Tirar Lenis/GSAP global, reduzir JS/long tasks, tokens/cache | Tasks 4–6, 10 e 11 |
|
||||
| CI com install/lint/tsc/test/build/audit; sem browser pesado | Task 12 |
|
||||
| Sem bypass de ad blocker; consentimento/revogação; CSP compatível | Task 11 |
|
||||
| README/deploy/project-health grounded | Task 12 |
|
||||
| Preservar identidade e não inventar claims | Restrições globais e smoke final |
|
||||
| Não integrar SEO externo nesta rodada | Restrições globais e não-objetivo do spec |
|
||||
| Preservar mudanças do usuário e não usar worktree/commits | Restrições globais, rollback do spec e Task 12 |
|
||||
|
||||
A auto-revisão confirmou que todas as interfaces, ordens, comandos, estados e critérios necessários estão definidos. Não há passo de commit. A execução termina somente após o gate completo e o smoke real.
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user