feat: harden portfolio and add SEO foundations

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-04 19:25:30 -03:00
1 parent b079afdd73
commit 62bcfbd8fa
121 files changed
+7769 -10469

No files matched your search

+52
View File
@@ -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,
);
}
+324
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
);
});
});
-503
View File
@@ -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);
});
});
-116
View File
@@ -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>
);
}
-911
View File
@@ -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>
);
}
-147
View File
@@ -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);
});
});
+127
View File
@@ -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"]);
});
});
+6 -2
View File
@@ -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 },
);
});
});
+1 -12
View File
@@ -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 />);
+220
View File
@@ -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();
});
});
});
+268 -19
View File
@@ -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 />;
}
+1 -1
View File
@@ -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"}
+67 -42
View File
@@ -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();
});
});
+43 -32
View File
@@ -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
View File
@@ -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" />
+161
View File
@@ -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
View File
@@ -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>
-39
View File
@@ -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>
);
}
+8 -2
View File
@@ -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" />
+12 -3
View File
@@ -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}
+60 -59
View File
@@ -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);
});
});
+26 -24
View File
@@ -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);
});
});
+12 -29
View File
@@ -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>
);
}
-119
View File
@@ -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 });
});
});
+34 -50
View File
@@ -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
-159
View File
@@ -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 -68
View File
@@ -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>
);
}
-11
View File
@@ -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"
-66
View File
@@ -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();
});
});
-107
View File
@@ -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,
);
}
+55 -76
View File
@@ -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)]">
&#x2022;
<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">
&#x2022;
</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>
);
}
+17 -87
View File
@@ -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>
);
}
+55 -5
View File
@@ -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 },
+20 -7
View File
@@ -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,
+82 -5
View File
@@ -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(() =>
+21 -6
View File
@@ -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}
-149
View File
@@ -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;
}
+48 -17
View File
@@ -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");
});
});
+12 -5
View File
@@ -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,
},
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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);
-56
View File
@@ -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
View File
@@ -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>
);
-70
View File
@@ -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;
-98
View File
@@ -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
View File
@@ -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
View File
@@ -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}
/>
-148
View File
@@ -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");
});
});
-93
View File
@@ -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;
}
-67
View File
@@ -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}</>;
}
+48
View File
@@ -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,
);
}
+251
View File
@@ -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` },
],
});
});
+40 -50
View File
@@ -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
View File
@@ -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
View File
@@ -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}`,
})),
];
}