feat(home): prova no topo, contato sem atrito e conversão medida

Home mais curta (hero, vitrine de clientes, sobre, serviços, IA resumida, depoimentos e contato), faixa de clientes e telas reais no hero, sem números do GitHub. Barra de cookies fina, WhatsApp fixo no celular, formulário com e-mail opcional e alternativa por e-mail, página /ia, eventos de e-mail, redes e currículo e conversão do Ads opcional por variável. Rodapé em 3 colunas. Remove componentes órfãos (LighthouseMetrics, Skills, Experience, contagem do GitHub).
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 03:32:23 -03:00
1 parent 6e1022b58c
commit 8d07b8c6bb
60 files changed
+4392 -2124

No files matched your search

-6
View File
@@ -41,12 +41,6 @@ corepack [email protected] start
| `pnpm test:watch` | Executa Jest em modo watch. |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
## Contagem de projetos do GitHub
O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHub (`lib/github-stats.ts`), consultada só no servidor e renovada uma vez por dia (`revalidate = 86400` em `app/page.tsx`). Conta repositórios próprios, públicos e privados, sem forks e sem arquivados.
Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece.
## Vitrine de vídeos dos projetos
Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`.
+158
View File
@@ -0,0 +1,158 @@
import { render, screen } from "@testing-library/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import About from "./About";
/**
* Testes de caminhos-base de About: todas as decisões dependem de `shouldReduceMotion`
* (variantes dos cards, transição, hover e toque), então há dois caminhos: movimento normal e reduzido.
* O `motion/react` é trocado por elementos simples que registram as props de animação.
*/
const mockSeen = jest.fn();
jest.mock("motion/react", () => {
const React = require("react");
const cache = new Map<string, unknown>();
const motion = new Proxy(
{},
{
get: (_target, tag: string) => {
if (!cache.has(tag)) {
cache.set(
tag,
({
children,
variants,
whileHover,
whileTap,
transition,
initial: _initial,
animate: _animate,
whileInView: _whileInView,
viewport: _viewport,
...props
}: Record<string, unknown> & { children?: unknown }) => {
mockSeen(tag, { variants, whileHover, whileTap, transition });
return React.createElement(tag, props, children);
},
);
}
return cache.get(tag);
},
},
);
return { motion };
});
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: jest.fn(),
}));
jest.mock("@/public/edev.jpg", () => ({
__esModule: true,
default: { src: "/edev.jpg", width: 10, height: 10 },
}));
jest.mock("./Section", () => ({
Section: ({
children,
id,
title,
}: {
children?: React.ReactNode;
id?: string;
title?: string;
}) => (
<section id={id}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
jest.mock("./animations/LazyRevealImage", () => ({
LazyRevealImage: ({ alt }: { alt: string }) => (
<span role="img" aria-label={alt} />
),
}));
type Seen = {
variants?: {
hidden: Record<string, unknown>;
visible: { transition: Record<string, unknown> };
};
whileHover?: unknown;
whileTap?: unknown;
};
function renderedCards(): Seen[] {
return mockSeen.mock.calls
.filter(([tag]) => tag === "article")
.map(([, seen]) => seen as Seen);
}
beforeEach(() => {
mockSeen.mockClear();
});
describe("About", () => {
it("path: normal motion -> cards rise and clip in, with hover lift and press feedback", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
render(<About />);
const cards = renderedCards();
expect(cards).toHaveLength(4); // 3 etapas da história + o card do perfil
for (const card of cards) {
expect(card.variants?.hidden).toEqual({
opacity: 0,
y: 20,
clipPath: "inset(100% 0 0 0)",
});
expect(card.variants?.visible.transition).toEqual({
duration: 0.8,
ease: [0.16, 1, 0.3, 1],
});
expect(card.whileHover).toEqual({
y: -4,
boxShadow: "0 18px 40px rgba(10,10,10,0.16)",
});
expect(card.whileTap).toEqual({ scale: 0.99 });
}
});
it("path: reduced motion -> cards only fade, with no hover lift and no press feedback", () => {
jest.mocked(useReducedMotion).mockReturnValue(true);
render(<About />);
const cards = renderedCards();
expect(cards).toHaveLength(4);
for (const card of cards) {
expect(card.variants?.hidden).toEqual({ opacity: 0 });
expect(card.variants?.visible.transition).toEqual({ duration: 0.25 });
expect(card.whileHover).toBeUndefined();
expect(card.whileTap).toBeUndefined();
}
});
it("path: content does not depend on motion -> same story, profile points and résumé link", () => {
for (const reduced of [false, true]) {
jest.mocked(useReducedMotion).mockReturnValue(reduced);
const { unmount } = render(<About />);
expect(
screen.getByRole("heading", {
level: 3,
name: "Do sinal físico ao produto digital",
}),
).toBeInTheDocument();
expect(screen.getByText("Arquitetura pragmática")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver trajetória completa" }),
).toHaveAttribute("href", "/curriculo");
unmount();
}
});
});
+8
View File
@@ -1,10 +1,12 @@
"use client";
import { motion, type Variants } from "motion/react";
import Link from "next/link";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage";
import { textLink } from "./button-styles";
import { Section } from "./Section";
const STORY_DATA = [
@@ -162,6 +164,12 @@ export default function About() {
</div>
))}
</div>
<Link
href="/curriculo"
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
>
Ver trajetória completa
</Link>
</motion.article>
</motion.div>
</Section>
+51
View File
@@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react";
import ClientStrip from "./ClientStrip";
/**
* Testes de caminhos-base de ClientStrip (complemento de ClientStrip.test.tsx): o separador entre clientes
* (um cliente, vários) e o className opcional.
*/
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
{ id: "lavacao-047", name: "Lavação 047" },
{ id: "sc-plus", name: "SC Plus Aquicultura" },
];
describe("ClientStrip", () => {
it("path: a single client -> no separator dot", () => {
const { container } = render(<ClientStrip clients={clients.slice(0, 1)} />);
expect(screen.getAllByRole("link")).toHaveLength(1);
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
});
it("path: several clients -> a separator after every client except the last", () => {
render(<ClientStrip clients={clients} />);
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
).toEqual([true, true, false]);
expect(items[0].querySelector("[aria-hidden='true']")).toHaveTextContent(
"·",
);
});
it("path: className given -> appended to the wrapper without a stray space", () => {
const { container } = render(
<ClientStrip clients={clients} className="hero-in mt-sp-4" />,
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.className.endsWith("hero-in mt-sp-4")).toBe(true);
});
it("path: className omitted -> the wrapper class has no trailing space", () => {
const { container } = render(<ClientStrip clients={clients} />);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.className).toBe(wrapper.className.trim());
});
});
+31
View File
@@ -0,0 +1,31 @@
import { render, screen, within } from "@testing-library/react";
import ClientStrip from "./ClientStrip";
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
{ id: "lavacao-047", name: "Lavação 047" },
];
it("lists each client as a link to its case", () => {
render(<ClientStrip clients={clients} />);
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
const list = screen.getByRole("list");
const links = within(list).getAllByRole("link");
expect(links.map((link) => link.textContent)).toEqual([
"SEV Moda Íntima",
"Lavação 047",
]);
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
]);
});
it("renders nothing without clients", () => {
const { container } = render(<ClientStrip clients={[]} />);
expect(container).toBeEmptyDOMElement();
const withoutProp = render(<ClientStrip />);
expect(withoutProp.container).toBeEmptyDOMElement();
});
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
export type ClientLink = { id: string; name: string };
/**
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case.
* Sem clientes, nada é desenhado.
*/
export default function ClientStrip({
clients = [],
className = "",
}: {
clients?: ClientLink[];
className?: string;
}) {
if (clients.length === 0) return null;
return (
<div
className={`flex flex-wrap items-baseline gap-x-sp-3 gap-y-sp-1 font-display text-[14px] leading-6 text-[rgba(244,242,237,.64)] sm:text-[15px] ${className}`.trim()}
>
<p>Já entregue para:</p>
<ul className="flex flex-wrap items-baseline gap-x-sp-3">
{clients.map((client, index) => (
<li key={client.id} className="flex items-baseline gap-x-sp-3">
<Link
href={`/cases/${client.id}`}
className="font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
>
{client.name}
</Link>
{/* o ponto fica no fim do item: numa quebra de linha ele nunca abre a linha seguinte */}
{index < clients.length - 1 && (
<span aria-hidden="true" className="text-[rgba(244,242,237,.4)]">
·
</span>
)}
</li>
))}
</ul>
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
/**
* Testes de caminhos-base de Contact (complemento de Contact.test.tsx): validate() campo a campo,
* foco no primeiro campo inválido, limpeza independente dos erros e montagem da mensagem.
*/
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn();
jest.spyOn(window, "open").mockImplementation(() => null);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
const NAME_ERROR = "Informe seu nome para eu saber com quem falo.";
const EMAIL_ERROR =
"Confira o e-mail: ele precisa ter o formato [email protected].";
const MESSAGE_ERROR = "Conte em poucas palavras o que você precisa resolver.";
const nameField = () => screen.getByRole("textbox", { name: /nome/i });
const emailField = () => screen.getByRole("textbox", { name: /e-mail/i });
const messageField = () => screen.getByRole("textbox", { name: /contexto/i });
const submitButton = () => screen.getByRole("button", { name: /whatsapp/i });
async function fill(
user: ReturnType<typeof userEvent.setup>,
values: { name?: string; email?: string; message?: string },
) {
if (values.name !== undefined) await user.type(nameField(), values.name);
if (values.email !== undefined) await user.type(emailField(), values.email);
if (values.message !== undefined)
await user.type(messageField(), values.message);
}
function sentMessage() {
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
return url.searchParams.get("text") ?? "";
}
describe("Contact: validate()", () => {
it("path: only the name is missing -> only the name is flagged and gets the focus", async () => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, {
email: "[email protected]",
message: "Organizar pedidos",
});
await user.click(submitButton());
expect(nameField()).toHaveAttribute("aria-invalid", "true");
expect(emailField()).not.toHaveAttribute("aria-invalid");
expect(messageField()).not.toHaveAttribute("aria-invalid");
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
expect(nameField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
});
it("path: only the e-mail is malformed -> only the e-mail is flagged and gets the focus (not the name)", async () => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, {
name: "Ana",
email: "ana@semponto",
message: "Organizar pedidos",
});
await user.click(submitButton());
expect(emailField()).toHaveAttribute("aria-invalid", "true");
expect(emailField()).toHaveAccessibleDescription(EMAIL_ERROR);
expect(nameField()).not.toHaveAttribute("aria-invalid");
expect(messageField()).not.toHaveAttribute("aria-invalid");
expect(emailField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
});
it("path: only the message is missing -> only the message is flagged and gets the focus", async () => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, { name: "Ana" });
await user.click(submitButton());
expect(messageField()).toHaveAttribute("aria-invalid", "true");
expect(messageField()).toHaveAccessibleDescription(
new RegExp(MESSAGE_ERROR),
);
expect(nameField()).not.toHaveAttribute("aria-invalid");
expect(emailField()).not.toHaveAttribute("aria-invalid");
expect(messageField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
});
it("path: name and message made only of spaces -> both rejected", async () => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, { name: " ", message: " " });
await user.click(submitButton());
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
expect(nameField()).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
});
it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, {
name: "Ana",
email: " ",
message: "Organizar pedidos",
});
await user.click(submitButton());
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(sentMessage()).not.toContain("E-mail:");
});
it.each(["a@b", "a@c.", "@c.com", "a@@c.com", "a [email protected]", "sem-arroba.com"])(
"path: e-mail %j -> invalid format is blocked",
async (email) => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
await user.click(submitButton());
expect(screen.getByText(EMAIL_ERROR)).toBeVisible();
expect(window.open).not.toHaveBeenCalled();
},
);
it.each(["[email protected]", "[email protected]"])(
"path: e-mail %j -> valid format is accepted and sent",
async (email) => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
await user.click(submitButton());
expect(screen.queryByText(EMAIL_ERROR)).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(sentMessage()).toContain(`E-mail: ${email}\n`);
},
);
});
describe("Contact: updateField() and the error state", () => {
it("path: typing in one field clears only that field's error", async () => {
const user = userEvent.setup();
render(<Contact />);
await user.click(submitButton());
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
await user.type(messageField(), "Organizar pedidos");
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
expect(messageField()).not.toHaveAttribute("aria-invalid");
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(nameField()).toHaveAttribute("aria-invalid", "true");
});
it("path: changing the project type keeps the other fields' errors untouched", async () => {
const user = userEvent.setup();
render(<Contact />);
await user.click(submitButton());
await user.selectOptions(
screen.getByRole("combobox", { name: /tipo de projeto/i }),
"Aplicativos",
);
expect(screen.getByText(NAME_ERROR)).toBeVisible();
expect(screen.getByText(MESSAGE_ERROR)).toBeVisible();
expect(
screen.getByRole("combobox", { name: /tipo de projeto/i }),
).toHaveValue("Aplicativos");
});
it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => {
const user = userEvent.setup();
render(<Contact />);
await user.click(submitButton());
expect(window.open).not.toHaveBeenCalled();
await fill(user, { name: "Ana", message: "Organizar pedidos" });
await user.click(submitButton());
expect(screen.queryByText(NAME_ERROR)).toBeNull();
expect(screen.queryByText(MESSAGE_ERROR)).toBeNull();
expect(window.open).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledTimes(1);
});
});
describe("Contact: mensagem montada", () => {
it("path: surrounding spaces are trimmed in name, e-mail and message", async () => {
const user = userEvent.setup();
render(<Contact />);
await fill(user, {
name: " Ana ",
email: " [email protected] ",
message: " Organizar pedidos ",
});
await user.click(submitButton());
const message = sentMessage();
expect(message).toContain("Nome: Ana\n");
expect(message).toContain("E-mail: [email protected]\n");
expect(message).toContain(
"Contexto, objetivo e prazo:\nOrganizar pedidos\n\n",
);
});
it("path: the chosen project type and the e-mail line reach the mailto draft, and the e-mail line is omitted when blank", async () => {
const user = userEvent.setup();
render(<Contact />);
await user.selectOptions(
screen.getByRole("combobox", { name: /tipo de projeto/i }),
"Aplicativos",
);
await fill(user, { name: "Ana", message: "Organizar pedidos" });
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
"body",
);
expect(body).toContain("Tipo de projeto: Aplicativos");
expect(body).not.toContain("E-mail:");
await user.type(emailField(), "[email protected]");
const withEmail = new URL(
screen
.getByRole("link", { name: "Prefere e-mail? Enviar por e-mail" })
.getAttribute("href") ?? "",
).searchParams.get("body");
expect(withEmail).toContain("E-mail: [email protected]\n");
});
it("path: the direct e-mail row reports its own placement", async () => {
const user = userEvent.setup();
render(<Contact />);
const direct = screen.getByRole("link", {
name: /evandrofonsecajunior@gmail\.com/,
});
// o jsdom não navega para mailto:; o clique só precisa chegar ao rastreio
direct.addEventListener("click", (event) => event.preventDefault());
await user.click(direct);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_direct" }),
);
});
});
+111
View File
@@ -103,3 +103,114 @@ it("opens the project draft with analytics unavailable and consent denied", asyn
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain("Organizar pedidos");
});
it("lets the visitor skip the e-mail and leaves that line out of the message", async () => {
const user = userEvent.setup();
render(<Contact />);
expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
const message = url.searchParams.get("text") ?? "";
expect(message).toContain("Nome: Ana");
expect(message).toContain("Tipo de projeto: Site para empresa");
expect(message).not.toContain("E-mail:");
});
it("offers 'Site para empresa' as the first and default project type", () => {
render(<Contact />);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
const options = Array.from(subject.querySelectorAll("option")).map(
(option) => option.value,
);
expect(options[0]).toBe("Site para empresa");
expect(subject).toHaveValue("Site para empresa");
});
it("explains each invalid field in Portuguese and marks it for assistive technology", async () => {
const user = userEvent.setup();
render(<Contact />);
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
const name = screen.getByRole("textbox", { name: /nome/i });
const email = screen.getByRole("textbox", { name: /e-mail/i });
const message = screen.getByRole("textbox", { name: /contexto/i });
for (const field of [name, email, message]) {
expect(field).toHaveAttribute("aria-invalid", "true");
}
expect(name).toHaveAccessibleDescription(
"Informe seu nome para eu saber com quem falo.",
);
expect(email).toHaveAccessibleDescription(
"Confira o e-mail: ele precisa ter o formato [email protected].",
);
expect(message).toHaveAccessibleDescription(
/Conte em poucas palavras o que você precisa resolver\./,
);
expect(name).toHaveFocus();
expect(window.open).not.toHaveBeenCalled();
await user.type(name, "Ana");
expect(name).not.toHaveAttribute("aria-invalid");
expect(
screen.queryByText("Informe seu nome para eu saber com quem falo."),
).toBeNull();
});
it("offers the same text by e-mail, below the WhatsApp button", async () => {
const user = userEvent.setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos & estoque",
);
const submit = screen.getByRole("button", { name: /whatsapp/i });
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
expect(
submit.compareDocumentPosition(mail) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const href = mail.getAttribute("href") ?? "";
expect(href.startsWith("mailto:[email protected]?")).toBe(true);
const url = new URL(href);
expect(url.searchParams.get("subject")).toBe("Projeto pelo site");
const body = url.searchParams.get("body") ?? "";
expect(body).toContain("Nome: Ana");
expect(body).toContain("Organizar pedidos & estoque");
expect(body).toContain("Tipo de projeto: Site para empresa");
await user.click(mail);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_form" }),
);
});
it("does not call the direct WhatsApp row 'Contato'", () => {
render(<Contact />);
const direct = screen.getByRole("link", { name: /Prefere conversar direto/ });
const text = new URL(direct.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toContain("Quero conversar sobre um projeto.");
expect(text).not.toMatch(/sobre Contato/);
});
+113 -38
View File
@@ -3,23 +3,52 @@
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import { trackWhatsAppOpen } from "@/lib/analytics";
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
import { CONTACT_EMAIL } from "@/lib/site-navigation";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import "./conversion.css";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons).
function WhatsAppMark({ className }: { className: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
focusable="false"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
);
type ContactField = "name" | "email" | "message";
type FieldErrors = Partial<Record<ContactField, string>>;
const FIELD_ERRORS: Record<ContactField, string> = {
name: "Informe seu nome para eu saber com quem falo.",
email: "Confira o e-mail: ele precisa ter o formato [email protected].",
message: "Conte em poucas palavras o que você precisa resolver.",
};
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FIELD_ORDER: ContactField[] = ["name", "email", "message"];
/** E-mail é opcional: só é erro se vier preenchido e fora do formato. */
function validate(data: {
name: string;
email: string;
message: string;
}): FieldErrors {
const errors: FieldErrors = {};
if (!data.name.trim()) errors.name = FIELD_ERRORS.name;
const email = data.email.trim();
if (email && !EMAIL_PATTERN.test(email)) errors.email = FIELD_ERRORS.email;
if (!data.message.trim()) errors.message = FIELD_ERRORS.message;
return errors;
}
/** Mesmo texto para o WhatsApp e para o e-mail; a linha do e-mail só entra se a pessoa informou um. */
function buildProjectMessage(data: {
name: string;
email: string;
subject: string;
message: string;
}) {
const email = data.email.trim();
return `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${data.name.trim()}\n${email ? `E-mail: ${email}\n` : ""}Tipo de projeto: ${data.subject}\n\nContexto, objetivo e prazo:\n${data.message.trim()}\n\nContato iniciado pelo site evandro.dev.br.`;
}
function buildMailtoUrl(message: string) {
return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent("Projeto pelo site")}&body=${encodeURIComponent(message)}`;
}
export default function Contact() {
@@ -29,21 +58,37 @@ export default function Contact() {
}, []);
const fieldClassName =
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] aria-[invalid=true]:border-[#ff8a80]";
const labelClassName = "block font-display text-sm font-medium text-white/85";
const errorClassName =
"mt-sp-2 font-display text-sm leading-6 text-[#ff9d94]";
const [formData, setFormData] = useState({
name: "",
email: "",
subject: "Sistemas web sob medida",
subject: "Site para empresa",
message: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
const updateField = (field: ContactField | "subject", value: string) => {
setFormData((current) => ({ ...current, [field]: value }));
if (field !== "subject" && errors[field]) {
setErrors((current) => ({ ...current, [field]: undefined }));
}
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!e.currentTarget.reportValidity()) return;
const messageTemplate = `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${formData.name}\nE-mail: ${formData.email}\nTipo de projeto: ${formData.subject}\n\nContexto, objetivo e prazo:\n${formData.message}\n\nContato iniciado pelo site evandro.dev.br.`;
const form = e.currentTarget;
const found = validate(formData);
setErrors(found);
const firstInvalid = FIELD_ORDER.find((field) => found[field]);
if (firstInvalid) {
form.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
return;
}
window.open(
buildWhatsAppUrl(messageTemplate),
buildWhatsAppUrl(buildProjectMessage(formData)),
"_blank",
"noopener,noreferrer",
);
@@ -81,7 +126,6 @@ export default function Contact() {
</div>
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
<WhatsAppLink
service="Contato"
placement="contact_direct"
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
@@ -102,12 +146,13 @@ export default function Contact() {
/>
</WhatsAppLink>
<a
href="mailto:[email protected]"
href={`mailto:${CONTACT_EMAIL}`}
onClick={() => trackEmailClick({ placement: "contact_direct" })}
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-all font-display text-sm">
evandrofonsecajunior@gmail.com
{CONTACT_EMAIL}
</span>
<ArrowUpRight
className="ml-auto h-4 w-4 shrink-0"
@@ -123,6 +168,7 @@ export default function Contact() {
<form
onSubmit={handleSubmit}
noValidate
aria-labelledby="contact-form-title"
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
@@ -155,30 +201,43 @@ export default function Contact() {
type="text"
autoComplete="name"
placeholder="Como posso te chamar?"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
aria-invalid={errors.name ? true : undefined}
aria-describedby={
errors.name ? "contact-name-error" : undefined
}
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
className={fieldClassName}
/>
{errors.name && (
<p id="contact-name-error" className={errorClassName}>
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="contact-email" className={labelClassName}>
E-mail
E-mail (opcional)
</label>
<input
id="contact-email"
name="email"
required
type="email"
autoComplete="email"
placeholder="[email protected]"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
aria-invalid={errors.email ? true : undefined}
aria-describedby={
errors.email ? "contact-email-error" : undefined
}
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
className={fieldClassName}
/>
{errors.email && (
<p id="contact-email-error" className={errorClassName}>
{errors.email}
</p>
)}
</div>
</div>
<div>
@@ -189,11 +248,10 @@ export default function Contact() {
id="contact-subject"
name="subject"
value={formData.subject}
onChange={(e) =>
setFormData({ ...formData, subject: e.target.value })
}
onChange={(e) => updateField("subject", e.target.value)}
className={`${fieldClassName} [color-scheme:dark]`}
>
<option value="Site para empresa">Site para empresa</option>
<option value="Sistemas web sob medida">
Sistemas web sob medida
</option>
@@ -220,22 +278,39 @@ export default function Contact() {
rows={4}
autoComplete="off"
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
aria-describedby="contact-privacy-note"
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
aria-invalid={errors.message ? true : undefined}
aria-describedby={
errors.message
? "contact-message-error contact-privacy-note"
: "contact-privacy-note"
}
value={formData.message}
onChange={(e) => updateField("message", e.target.value)}
className={`${fieldClassName} min-h-32 resize-y`}
/>
{errors.message && (
<p id="contact-message-error" className={errorClassName}>
{errors.message}
</p>
)}
</div>
<button
type="submit"
className="flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 font-display text-base font-semibold leading-6 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
className="cv-btn flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
</button>
<p className="text-center font-display text-[15px] leading-6 text-white/70">
<a
href={buildMailtoUrl(buildProjectMessage(formData))}
onClick={() => trackEmailClick({ placement: "contact_form" })}
className="inline-flex min-h-11 items-center underline underline-offset-4 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
Prefere e-mail? Enviar por e-mail
</a>
</p>
<p
id="contact-privacy-note"
className="font-display text-xs leading-6 text-white/55"
+36
View File
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import EngineeringWithAI from "./EngineeringWithAI";
jest.mock("./Section", () => ({
Section: ({
children,
title,
description,
}: {
children: ReactNode;
title: string;
description?: string;
}) => (
<section>
<h2>{title}</h2>
<p>{description}</p>
{children}
</section>
),
}));
it("is the short home version and points to /ia for the full content", () => {
render(<EngineeringWithAI />);
expect(
screen.getByRole("heading", {
name: "Já começou com IA? Dá para partir daí.",
}),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Entender como funciona" }),
).toHaveAttribute("href", "/ia");
// os incidentes e a LGPD ficam só em /ia
expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull();
});
+8 -201
View File
@@ -1,213 +1,20 @@
import Link from "next/link";
import { secondaryButtonLight } from "./button-styles";
import { Section } from "./Section";
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
description:
"Login não basta. Permissões precisam ser verificadas no servidor e testadas entre usuários, empresas e perfis diferentes.",
},
{
title: "Como recuperar a operação?",
description:
"Ambientes separados, acesso restrito à produção e backups com restauração testada reduzem o impacto de uma alteração errada.",
},
{
title: "Quem valida antes de publicar?",
description:
"Código gerado também passa por revisão, testes e avaliação de dependências. O escopo inclui manutenção, custos e riscos de operação.",
},
];
/** Versão curta da home. O conteúdo completo (e os incidentes citados) fica em /ia. */
export default function EngineeringWithAI() {
return (
<Section
id="engineering-ai"
eyebrow="IA no desenvolvimento"
title="Se a IA cria um sistema, por que contratar um desenvolvedor?"
description="A IA permite testar ideias e construir protótipos mesmo sem experiência em programação. Quando o sistema passa a cuidar de clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que foi gerado e validar como ele se comporta fora da demonstração."
title="Já começou com IA? Dá para partir daí."
description="Seu protótipo ajuda a explicar a ideia. Antes de propor uma reconstrução, avalio o que pode ser aproveitado, quais riscos precisam de correção e o que falta para atender usuários em produção."
disableChildrenAnimation
>
<div className="border-l-2 border-[var(--slash)] pl-sp-5">
<p className="max-w-[75ch] text-lg leading-8 text-[var(--ink)]">
Você contrata engenharia para transformar as regras do seu negócio em
um sistema que possa ser testado, operado e mantido. A IA pode fazer
parte desse trabalho. Ela não dispensa decisões sobre arquitetura,
segurança e responsabilidade pelos dados.
</p>
</div>
<div className="mt-sp-8 grid gap-sp-6 md:grid-cols-3">
{RESPONSIBILITIES.map((item) => (
<article
key={item.title}
className="border-t border-[var(--border)] pt-sp-5"
>
<h3 className="text-lg font-semibold leading-snug">{item.title}</h3>
<p className="ds-copy">{item.description}</p>
</article>
))}
</div>
<div className="mt-sp-12">
<h3 className="ds-card-title">O que casos documentados ensinam</h3>
<p className="ds-copy max-w-[75ch]">
São episódios de 2025, não avaliações atuais das plataformas. Eles
mostram riscos de configuração, permissões e operação; não provam que
todo sistema feito com IA seja inseguro ou que um profissional elimine
qualquer risco.
</p>
<div className="mt-sp-6 space-y-sp-3">
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Dados expostos em aplicações criadas com Lovable
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em 29 de maio de 2025, a Semafor noticiou uma análise de Matt
Palmer e Kody Low: 170 de 1.645 aplicações examinadas permitiam
acesso indevido a informações, incluindo dados de usuários e
chaves de API. A reportagem relacionou a exposição à
configuração de acesso dos bancos conectados às aplicações.
</p>
<p>
O registro CVE-2025-48757 descreve políticas insuficientes de
acesso por registro no banco. O fornecedor contesta a atribuição
da vulnerabilidade à plataforma e ressalta a responsabilidade
dos clientes por seus dados. O relato de exposição não comprova
roubo por terceiros em todas essas aplicações.
</p>
<p>
<strong>O cuidado técnico:</strong> testar se uma pessoa
consegue ler ou alterar dados de outra, em vez de considerar a
tela de login uma prova de segurança.
</p>
<div className="flex flex-wrap gap-x-sp-6 gap-y-sp-2">
<a
className="underline underline-offset-4"
href="https://www.semafor.com/article/05/29/2025/the-hottest-new-vibe-coding-startup-lovable-is-a-sitting-duck-for-hackers"
target="_blank"
rel="noopener noreferrer"
>
Reportagem da Semafor (em inglês)
</a>
<a
className="underline underline-offset-4"
href="https://nvd.nist.gov/vuln/detail/CVE-2025-48757"
target="_blank"
rel="noopener noreferrer"
>
Registro e contestação no NVD
</a>
</div>
</div>
</details>
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Replit: um agente apagou dados de uma aplicação
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em julho de 2025, Jason Lemkin, cofundador da SaaStr, relatou
que um agente apagou dados durante a construção de uma
aplicação. A Replit reconheceu o episódio e informou que o banco
foi integralmente recuperado por rollback. Naquele projeto,
alterações de desenvolvimento podiam afetar a produção.
</p>
<p>
A plataforma anunciou separação entre bancos de desenvolvimento
e produção por padrão. <strong>O cuidado técnico:</strong>{" "}
limitar permissões de agentes, separar ambientes e testar a
recuperação antes de depender dela. Neste caso houve exclusão e
recuperação de dados, não roubo de informações.
</p>
<a
className="inline-block underline underline-offset-4"
href="https://replit.com/blog/doubling-down-on-our-commitment-to-secure-vibe-coding"
target="_blank"
rel="noopener noreferrer"
>
Relato e medidas publicadas pela Replit (29/07/2025)
</a>
</div>
</details>
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Base44: aplicações privadas com falha de acesso
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em julho de 2025, pesquisadores da Wiz demonstraram uma falha
que permitia acesso não autorizado a aplicações privadas do
Base44. A falha estava na camada compartilhada de autenticação
da plataforma, não em um erro comprovado de um usuário
iniciante.
</p>
<p>
A Wix corrigiu o problema em menos de 24 horas após a
notificação e informou não ter encontrado evidência de
exploração maliciosa. <strong>O cuidado técnico:</strong>{" "}
avaliar também o fornecedor e as permissões efetivas das
integrações, além do código da aplicação.
</p>
<a
className="inline-block underline underline-offset-4"
href="https://www.wiz.io/blog/critical-vulnerability-base44"
target="_blank"
rel="noopener noreferrer"
>
Pesquisa da Wiz e resposta da Wix (29/07/2025)
</a>
</div>
</details>
</div>
</div>
<div className="mt-sp-8 grid gap-sp-6 lg:grid-cols-2">
<article className="ds-card">
<h3 className="ds-card-title !mt-0">E a LGPD?</h3>
<p className="ds-copy">
Usar IA não afasta as obrigações de proteção de dados. A ANPD exige
medidas técnicas e administrativas também de agentes de pequeno
porte e esclarece que as flexibilizações não os isentam das demais
obrigações da LGPD, como bases legais e direitos dos titulares.
</p>
<p className="ds-copy">
O trabalho técnico inclui controles de acesso, coleta limitada ao
necessário e rotinas de retenção e exclusão. Conformidade depende
também dos processos da empresa e de orientação jurídica; contratar
desenvolvimento não equivale a uma certificação de adequação.
</p>
<a
className="mt-sp-4 inline-block text-sm underline underline-offset-4"
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
target="_blank"
rel="noopener noreferrer"
>
ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13
</a>
</article>
<article className="ds-card">
<h3 className="ds-card-title !mt-0">
Já começou com IA? Podemos partir daí.
</h3>
<p className="ds-copy">
Seu protótipo ajuda a explicar a ideia. Antes de propor uma
reconstrução, avalio o que pode ser aproveitado, quais riscos
precisam de correção e o que falta para atender usuários em
produção.
</p>
<p className="ds-copy">
Você não precisa saber programar para conduzir o negócio. Precisa
entender o que está contratando, acompanhar a entrega e ter clareza
sobre quem cuida de cada parte do sistema.
</p>
<a className="ds-link ds-link-accent mt-sp-6" href="/#contact">
Avaliar meu projeto
</a>
</article>
</div>
<Link href="/ia" className={secondaryButtonLight}>
Entender como funciona
</Link>
</Section>
);
}
-127
View File
@@ -1,127 +0,0 @@
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"]);
});
});
-114
View File
@@ -1,114 +0,0 @@
"use client";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
import { Section } from "./Section";
export default function Experience({
experiences = [],
}: {
experiences?: PortfolioExperience[];
}) {
const shouldReduceMotion = useReducedMotion();
const sortedExperiences = [...experiences]
.filter((experience) => experience.featured)
.sort((a, b) => {
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
return yearB - yearA;
});
const listVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.08,
staggerChildren: 0.08,
},
},
};
const itemVariants: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
visible: {
opacity: 1,
y: 0,
clipPath: "inset(0% 0 0 0)",
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
},
};
return (
<Section
id="experience"
eyebrow="Selected experience"
title="Experiência em engenharia de software e sistemas críticos"
description="Projetos e contextos em que combinei arquitetura de software, integrações complexas e entrega com responsabilidade operacional."
disableChildrenAnimation
>
<motion.div
className="space-y-sp-6"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.18 }}
variants={listVariants}
>
{sortedExperiences.map((exp) => (
<motion.article
key={exp.id}
className="ds-card h-full"
variants={itemVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 16px 36px rgba(10,10,10,0.15)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
transition={{ type: "spring", stiffness: 170, damping: 20 }}
>
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-start lg:justify-between">
<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>
<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>
</div>
<div className="ds-metric">{exp.location}</div>
</div>
<p className="ds-copy" data-reel="right">
{exp.description}
</p>
<ul className="ds-list">
{exp.achievements?.map((achievement) => (
<li key={achievement}>{achievement}</li>
))}
</ul>
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{exp.technologies?.slice(0, 6).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
</motion.article>
))}
</motion.div>
</Section>
);
}
@@ -0,0 +1,242 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import FloatingWhatsApp from "./FloatingWhatsApp";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Testes de caminhos-base de FloatingWhatsApp (complemento de FloatingWhatsApp.test.tsx):
* cada convite (botão do hero, seção de contato) sozinho na página, relatórios parciais do observador,
* desconexão ao desmontar e a convivência com a barra de cookies.
* A regra "só abaixo de 768 px" é CSS (md:hidden); o jsdom não avalia media queries, então só a classe é conferida.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
const instances: FakeObserver[] = [];
const originalObserver = global.IntersectionObserver;
class FakeObserver {
observed: Element[] = [];
disconnect = jest.fn();
constructor(private callback: IntersectionObserverCallback) {
instances.push(this);
}
observe = jest.fn((target: Element) => {
this.observed.push(target);
});
unobserve = jest.fn();
takeRecords = () => [];
report(entries: Entry[]) {
act(() =>
this.callback(entries as IntersectionObserverEntry[], this as never),
);
}
}
const hero = () => document.getElementById("hero-contact-cta") as Element;
const contact = () => document.getElementById("contact") as Element;
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
beforeEach(() => {
instances.length = 0;
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
window.gtag = jest.fn();
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
document.documentElement.style.removeProperty("--cookie-bar-height");
document.documentElement.removeAttribute("data-cookie-bar");
});
function renderBoth() {
return render(
<>
<a id="hero-contact-cta" href="/#contact">
Conversar
</a>
<section id="contact" aria-label="Contato" />
<FloatingWhatsApp />
</>,
);
}
describe("FloatingWhatsApp: convites presentes na página", () => {
it("path: only the hero button exists -> observes just it and follows its visibility", () => {
render(
<>
<a id="hero-contact-cta" href="/#contact">
Conversar sobre meu projeto
</a>
<FloatingWhatsApp />
</>,
);
expect(instances).toHaveLength(1);
expect(instances[0].observed).toEqual([hero()]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: hero(), isIntersecting: true }]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: only the contact section exists -> observes just it and follows its visibility", () => {
render(
<>
<section id="contact" aria-label="Contato" />
<FloatingWhatsApp />
</>,
);
expect(instances[0].observed).toEqual([contact()]);
instances[0].report([{ target: contact(), isIntersecting: true }]);
expect(floatingLink()).toBeNull();
instances[0].report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: neither invite exists -> no observer is created and the button shows at once", () => {
render(<FloatingWhatsApp />);
expect(instances).toHaveLength(0);
expect(floatingLink()).toBeInTheDocument();
});
it("path: both invites exist -> both are observed", () => {
renderBoth();
expect(instances[0].observed).toEqual([hero(), contact()]);
});
});
describe("FloatingWhatsApp: relatórios do observador", () => {
it("path: a later report about one invite keeps what was said about the other", () => {
renderBoth();
const [observer] = instances;
observer.report([
{ target: hero(), isIntersecting: true },
{ target: contact(), isIntersecting: false },
]);
expect(floatingLink()).toBeNull();
// só o contato é reportado de novo; o hero continua à vista
observer.report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeNull();
observer.report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: both invites on screen at once -> hidden until both leave", () => {
renderBoth();
const [observer] = instances;
observer.report([
{ target: hero(), isIntersecting: true },
{ target: contact(), isIntersecting: true },
]);
expect(floatingLink()).toBeNull();
observer.report([{ target: hero(), isIntersecting: false }]);
expect(floatingLink()).toBeNull();
observer.report([{ target: contact(), isIntersecting: false }]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: an empty report -> nothing is visible, so the button shows", () => {
renderBoth();
instances[0].report([]);
expect(floatingLink()).toBeInTheDocument();
});
it("path: unmount -> the observer is disconnected", () => {
const { unmount } = renderBoth();
expect(instances[0].disconnect).not.toHaveBeenCalled();
unmount();
expect(instances[0].disconnect).toHaveBeenCalledTimes(1);
});
});
describe("FloatingWhatsApp: conteúdo e cliques", () => {
it("path: the shortcut is CSS-limited to small screens and sized as a 56px target", () => {
render(<FloatingWhatsApp />);
const link = floatingLink();
expect(link).toHaveClass("h-14", "w-14");
expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4");
});
it("path: click reports 'Contato' as the service because the floating link has no subject", () => {
render(<FloatingWhatsApp />);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: "Contato", placement: "floating" }),
);
});
});
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
it("path: the bar publishes its height while open and removes it once a choice is made", async () => {
const user = userEvent.setup();
localStorage.clear();
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 72,
});
try {
render(
<>
<GoogleAnalytics />
<FloatingWhatsApp />
</>,
);
const refuse = await screen.findByRole("button", { name: "Recusar" });
// o botão flutuante usa a variável publicada pela barra para subir
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
);
await user.click(refuse);
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
}
});
});
+134
View File
@@ -0,0 +1,134 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import FloatingWhatsApp from "./FloatingWhatsApp";
const CONSENT_KEY = "evandro.analytics-consent.v1";
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
let notify: (entries: Entry[]) => void = () => {};
const originalObserver = global.IntersectionObserver;
class FakeObserver {
constructor(callback: IntersectionObserverCallback) {
notify = (entries) =>
callback(entries as IntersectionObserverEntry[], this as never);
}
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
takeRecords = () => [];
}
function Page({ withInvites = true }: { withInvites?: boolean }) {
return (
<>
{withInvites && (
<>
<a id="hero-contact-cta" href="/#contact">
Conversar sobre meu projeto
</a>
<section id="contact" aria-label="Contato" />
</>
)}
<FloatingWhatsApp />
</>
);
}
function setVisibility(hero: boolean, contact: boolean) {
act(() =>
notify([
{
target: document.getElementById("hero-contact-cta") as Element,
isIntersecting: hero,
},
{
target: document.getElementById("contact") as Element,
isIntersecting: contact,
},
]),
);
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
beforeEach(() => {
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
window.gtag = jest.fn();
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
it("stays hidden until the observer reports, then shows a 56px WhatsApp shortcut for small screens", () => {
render(<Page />);
expect(floatingLink()).toBeNull();
setVisibility(false, false);
const link = floatingLink();
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
const url = new URL(link?.getAttribute("href") ?? "");
expect(url.origin).toBe("https://wa.me");
expect(url.searchParams.get("text")).toContain(
"Quero conversar sobre um projeto.",
);
// alvo de 56 px, canto inferior direito, só abaixo de 768 px
expect(link).toHaveClass("h-14", "w-14");
expect(link?.parentElement).toHaveClass("fixed", "right-sp-4", "md:hidden");
});
it("hides while the hero button is on screen", () => {
render(<Page />);
setVisibility(true, false);
expect(floatingLink()).toBeNull();
setVisibility(false, false);
expect(floatingLink()).toBeInTheDocument();
});
it("hides while the contact section is on screen", () => {
render(<Page />);
setVisibility(false, true);
expect(floatingLink()).toBeNull();
setVisibility(false, false);
expect(floatingLink()).toBeInTheDocument();
});
it("shows right away on a page that has neither the hero button nor the contact section", () => {
render(<Page withInvites={false} />);
expect(floatingLink()).toBeInTheDocument();
});
it("lifts itself by the height of the open cookie bar", () => {
render(<Page />);
setVisibility(false, false);
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
);
});
it("reports the click as a floating WhatsApp handoff", () => {
render(<Page />);
setVisibility(false, false);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "floating" }),
);
});
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
/** Botão principal do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
export const HERO_CTA_ID = "hero-contact-cta";
const CONTACT_SECTION_ID = "contact";
/**
* Atalho para o WhatsApp só no celular (abaixo de 768 px). Fica escondido quando já há um convite de contato
* à vista e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
*/
export default function FloatingWhatsApp() {
// null até o observador responder pela primeira vez: assim o botão não pisca na chegada, quando o hero está à vista
const [inviteVisible, setInviteVisible] = useState<Record<
string,
boolean
> | null>(null);
useEffect(() => {
const targets = [HERO_CTA_ID, CONTACT_SECTION_ID]
.map((id) => document.getElementById(id))
.filter((element): element is HTMLElement => element !== null);
if (targets.length === 0) {
setInviteVisible({});
return;
}
const observer = new IntersectionObserver((entries) => {
setInviteVisible((current) => {
const next = { ...current };
for (const entry of entries) {
next[entry.target.id] = entry.isIntersecting;
}
return next;
});
});
for (const target of targets) observer.observe(target);
return () => observer.disconnect();
}, []);
if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null;
return (
<div className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] z-[8900] transition-[bottom] duration-200 motion-reduce:transition-none md:hidden">
<WhatsAppLink
placement="floating"
className="flex h-14 w-14 items-center justify-center rounded-full bg-[#25d366] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[#54e58b] motion-reduce:transition-none"
>
<WhatsAppMark className="h-7 w-7" />
<span className="sr-only">Conversar no WhatsApp</span>
</WhatsAppLink>
</div>
);
}
+77 -37
View File
@@ -1,4 +1,6 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { OPEN_COOKIE_PREFERENCES_EVENT } from "@/lib/analytics";
import { serviceHubLinks } from "@/lib/site-content";
import Footer from "./Footer";
describe("Footer contact and navigation", () => {
@@ -20,56 +22,94 @@ describe("Footer contact and navigation", () => {
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("renders quick links as same-tab navigation", () => {
render(<Footer />);
it("lists the six services from the services hub as same-tab links", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
expect(servicesLink).toHaveAttribute("href", "/servicos");
expect(servicesLink).not.toHaveAttribute("target", "_blank");
expect(screen.getByRole("link", { name: "Privacidade" })).toHaveAttribute(
"href",
"/privacidade",
expect(serviceHubLinks).toHaveLength(6);
const services = screen.getByRole("navigation", { name: "Serviços" });
const links = within(services).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
serviceHubLinks.map((service) => service.href),
);
expect(links.map((link) => link.textContent)).toEqual(
serviceHubLinks.map((service) => service.label),
);
for (const link of links) expect(link).not.toHaveAttribute("target");
});
it("keeps the résumé page and PDF download as the final secondary links", () => {
it("links Cases, Privacidade and a discreet Currículo, with no PDF download", () => {
render(<Footer />);
const résuméLink = screen.getByRole("link", { name: "Currículo" });
const downloadLink = screen.getByRole("link", {
name: "Baixar currículo (PDF)",
});
expect(résuméLink).toHaveAttribute("href", "/curriculo");
expect(downloadLink).toHaveAttribute("href", "/Profile.pdf");
expect(downloadLink).toHaveAttribute(
"download",
"Evandro_Fonseca_Junior_CV.pdf",
const more = screen.getByRole("navigation", { name: "Mais" });
expect(within(more).getByRole("link", { name: "Cases" })).toHaveAttribute(
"href",
"/cases",
);
expect(screen.getAllByRole("link").slice(-2)).toEqual([
résuméLink,
downloadLink,
]);
expect(
within(more).getByRole("link", { name: "Privacidade" }),
).toHaveAttribute("href", "/privacidade");
const resume = within(more).getByRole("link", { name: "Currículo" });
expect(resume).toHaveAttribute("href", "/curriculo");
expect(within(more).getAllByRole("link").at(-1)).toBe(resume);
expect(screen.queryByRole("link", { name: /baixar|pdf/i })).toBeNull();
for (const link of screen.getAllByRole("link")) {
expect(link).not.toHaveAttribute("download");
}
});
it("reopens the cookie preferences from a button", () => {
const reopen = jest.fn();
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
render(<Footer />);
fireEvent.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
expect(reopen).toHaveBeenCalledTimes(1);
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
});
it("states the location and rights in Portuguese", () => {
render(<Footer />);
expect(
screen.getByText("Garuva, SC · Atendimento remoto em todo o Brasil"),
).toBeInTheDocument();
expect(
screen.getByText(/Todos os direitos\s+reservados/),
).toBeInTheDocument();
expect(screen.queryByText(/remote worldwide|all rights/i)).toBeNull();
});
});
describe("Footer social links", () => {
it("opens social links securely in a new tab", () => {
it("keeps one LinkedIn profile in a new tab and no GitHub link", () => {
render(<Footer />);
const socialLinks = screen.getAllByRole("link", { name: /profile/i });
for (const link of socialLinks) {
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
}
expect(screen.getByLabelText("Visit GitHub profile")).toHaveAttribute(
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
expect(linkedin).toHaveAttribute(
"href",
"https://github.com/evandrodevbr",
"https://www.linkedin.com/in/evandrodevbr/",
);
expect(
screen.getByLabelText("Visit LinkedIn profile").getAttribute("href"),
).toContain("https://www.linkedin.com/in/evandrodevbr/");
expect(linkedin).toHaveAttribute("target", "_blank");
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
}
expect(screen.queryByRole("link", { name: /github/i })).toBeNull();
});
it("keeps Instagram in the contact column, in a new tab", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const instagram = within(contact).getByRole("link", { name: "Instagram" });
expect(instagram).toHaveAttribute(
"href",
"https://www.instagram.com/evandrodevbr/",
);
expect(instagram).toHaveAttribute("target", "_blank");
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
});
+126 -151
View File
@@ -1,168 +1,143 @@
export interface SocialLink {
name: string;
href: string;
icon: string;
ariaLabel: string;
}
"use client";
const socialLinks: SocialLink[] = [
{
name: "GitHub",
href: "https://github.com/evandrodevbr",
icon: "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",
ariaLabel: "Visit GitHub profile",
},
{
name: "LinkedIn",
href: "https://www.linkedin.com/in/evandrodevbr/?skipRedirect=true",
icon: "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",
ariaLabel: "Visit LinkedIn profile",
},
{
name: "Instagram",
href: "https://www.instagram.com/evandrodevbr/",
icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.85.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.85-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z",
ariaLabel: "Visit Instagram profile",
},
];
const quickLinks = [
{ label: "Hub de serviços", href: "/servicos" },
{ label: "Cases", href: "/cases" },
{ label: "Privacidade", href: "/privacidade" },
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
{
label: "Integração hardware + software",
href: "/servicos/integracao-hardware-software-iot",
},
];
import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation";
import MagneticButton from "./cinematic/MagneticButton";
import Link from "next/link";
import {
openCookiePreferences,
trackEmailClick,
trackSocialClick,
} from "@/lib/analytics";
import {
CONTACT_EMAIL,
INSTAGRAM_URL,
LINKEDIN_URL,
type ServiceLink,
} from "@/lib/site-navigation";
import "./conversion.css";
import { Logo } from "./Logo";
export default function Footer() {
const headingClassName =
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
const linkClassName =
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
export default function Footer({
serviceLinks = [],
}: {
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
serviceLinks?: ServiceLink[];
}) {
const currentYear = new Date().getFullYear();
return (
<footer className="ds-section">
<footer className="border-t border-[var(--border)] bg-[var(--paper)] px-[clamp(20px,6vw,64px)] py-sp-10 text-[var(--ink)]">
<div className="ds-section-shell">
<div className="ds-grid-2">
<div className="ds-card flex flex-col justify-between">
<div>
<div className="ds-kicker">Contato</div>
<span className="ds-draw-line" />
<div className="mt-sp-5">
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
E-mail direto
</p>
<MagneticButton
as="a"
href="mailto:[email protected]"
className="inline-flex min-h-11 items-center break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
strength={0.2}
<div className="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10">
<section aria-labelledby="footer-contact-title">
<p id="footer-contact-title" className={headingClassName}>
Contato
</p>
<ul className="mt-sp-2">
<li>
<a
href={`mailto:${CONTACT_EMAIL}`}
onClick={() => trackEmailClick({ placement: "footer" })}
className={`${linkClassName} break-all font-semibold !text-[var(--ink)]`}
>
evandrofonsecajunior@gmail.com
</MagneticButton>
</div>
</div>
{CONTACT_EMAIL}
</a>
</li>
<li>
<a
href={LINKEDIN_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackSocialClick({ network: "linkedin" })}
className={linkClassName}
>
LinkedIn
</a>
</li>
<li>
<a
href={INSTAGRAM_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackSocialClick({ network: "instagram" })}
className={linkClassName}
>
Instagram
</a>
</li>
</ul>
<p className="mt-sp-2 font-display text-[15px] leading-6 text-[var(--meta)]">
Garuva, SC · Atendimento remoto em todo o Brasil
</p>
</section>
<div className="mt-sp-8">
<a
href="/#home"
aria-label="Evandro.dev.br — início"
className="mb-sp-6 inline-flex min-h-11 max-w-full items-center"
>
<Logo theme="light" className="w-60" />
</a>
<div className="ds-metric">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
Garuva, SC | Remote worldwide
</div>
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
Copyright {currentYear} Evandro Fonseca Junior. All rights
reserved.
</p>
</div>
</div>
<nav aria-labelledby="footer-services-title">
<p id="footer-services-title" className={headingClassName}>
Serviços
</p>
<ul className="mt-sp-2">
{serviceLinks.map((service) => (
<li key={service.href}>
<Link href={service.href} className={linkClassName}>
{service.label}
</Link>
</li>
))}
</ul>
</nav>
<div className="ds-card flex flex-col justify-between">
<div>
<div className="ds-kicker">Presença digital</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">Canais profissionais</h3>
<div className="mt-sp-6 flex flex-col gap-sp-4">
{socialLinks.map((link) => (
<MagneticButton
key={link.name}
as="a"
href={link.href}
target="_blank"
rel="noopener noreferrer"
aria-label={link.ariaLabel}
className="group flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[rgba(10,10,10,0.02)] p-sp-4 transition-all duration-300 hover:border-[var(--slash)] hover:bg-[rgba(10,10,10,0.04)]"
strength={0.15}
>
<div className="flex items-center gap-sp-3">
<svg
className="h-5 w-5 text-[var(--meta)] transition-colors group-hover:text-[var(--ink)]"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d={link.icon} />
</svg>
<span className="text-[14px] font-medium text-[var(--ink-80)] transition-colors group-hover:text-[var(--ink)]">
{link.name}
</span>
</div>
<span className="text-[12px] tracking-[0.08em] text-[var(--meta)] transition-colors group-hover:text-[var(--slash)]">
abrir
</span>
</MagneticButton>
))}
</div>
<div className="mt-sp-8 border-t border-[var(--border)] pt-sp-5">
<div className="ds-kicker">Explorar</div>
<div className="mt-sp-4 grid gap-sp-3 md:grid-cols-2">
{quickLinks.map((link) => (
<MagneticButton
key={link.href}
as="a"
href={link.href}
className="block rounded-[8px] border border-[var(--border)] bg-[rgba(10,10,10,0.02)] px-sp-4 py-sp-3 text-[13px] leading-6 text-[var(--ink-80)] transition-colors duration-200 hover:border-[var(--slash)] hover:text-[var(--ink)]"
wrapperClassName="block"
strength={0.15}
>
{link.label}
</MagneticButton>
))}
</div>
</div>
</div>
<div className="mt-sp-8 ds-metric">
Software sob medida, contato direto
</div>
</div>
<nav aria-labelledby="footer-links-title">
<p id="footer-links-title" className={headingClassName}>
Mais
</p>
<ul className="mt-sp-2">
<li>
<Link href="/cases" className={linkClassName}>
Cases
</Link>
</li>
<li>
<Link href="/privacidade" className={linkClassName}>
Privacidade
</Link>
</li>
<li>
<button
type="button"
onClick={openCookiePreferences}
className={`cv-btn-link ${linkClassName}`}
>
Preferências de cookies
</button>
</li>
<li>
<Link
href="/curriculo"
className={`${linkClassName} !text-[14px] !text-[var(--meta)]`}
>
Currículo
</Link>
</li>
</ul>
</nav>
</div>
<div className="mt-sp-8 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2 border-t border-[var(--border)] pt-sp-4 text-[12px] text-[var(--meta)]">
<span>Trajetória profissional</span>
<div className="mt-sp-8 flex flex-wrap items-center justify-between gap-x-sp-6 gap-y-sp-3 border-t border-[var(--border)] pt-sp-5">
<a
href="/curriculo"
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
href="/#home"
aria-label="Evandro.dev.br — início"
className="inline-flex min-h-11 items-center"
>
Currículo
</a>
<a
href={CV_URL}
download={CV_DOWNLOAD_NAME}
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
>
Baixar currículo (PDF)
<Logo theme="light" className="w-40" />
</a>
<p className="font-display text-[14px] leading-6 text-[var(--meta)]">
© {currentYear} Evandro Fonseca Junior. Todos os direitos
reservados.
</p>
</div>
</div>
</footer>
@@ -0,0 +1,69 @@
/**
* @jest-environment-options {"url": "https://evandro.dev.br/"}
*/
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Caminho de GoogleAnalytics (expireAnalyticsCookies) quando o site roda no próprio domínio evandro.dev.br:
* host e domínio raiz são o mesmo, então só há um atributo Domain.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it("path: host is the site domain itself -> expires _ga on the path and once on the domain", async () => {
const user = userEvent.setup();
expect(window.location.hostname).toBe("evandro.dev.br");
setCookie("_ga=1; Path=/");
setCookie("_ga_ABC=1; Path=/");
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await act(async () => {});
await user.click(screen.getByRole("button", { name: "Recusar" }));
const writes = cookieWrites.mock.calls.map(([value]) => String(value));
// dois cookies (_ga e _ga_ABC), cada um: sem Domain e com Domain=evandro.dev.br (host == raiz, sem duplicar)
expect(writes).toHaveLength(4);
expect(writes.filter((w) => w.includes("Domain="))).toEqual([
expect.stringMatching(/^_ga=.*; Domain=evandro\.dev\.br$/),
expect.stringMatching(/^_ga_ABC=.*; Domain=evandro\.dev\.br$/),
]);
});
@@ -0,0 +1,418 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Testes de caminhos-base de GoogleAnalytics (complemento de GoogleAnalytics.test.tsx):
* modo de desenvolvimento, teclas da barra, reaceite, nova concessão depois de revogar,
* cookies de analytics x outros cookies, reaproveitamento de gtag/dataLayer e recuperação de falha de gravação.
*/
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";
type AnalyticsWindow = Window & {
dataLayer?: IArguments[];
gtag?: (...args: unknown[]) => void;
[GA_DISABLE_KEY]?: boolean;
};
const analyticsWindow = window as AnalyticsWindow;
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
function expireCookie(name: string) {
setCookie(`${name}=; Max-Age=0; Path=/`);
}
const COOKIES = ["_ga", "_ga_ABC123", "_gid", "_gat", "portfolio_session"];
function commands() {
return (analyticsWindow.dataLayer ?? []).map((entry) => Array.from(entry));
}
function commandsNamed(...names: string[]) {
return commands().filter((entry) =>
names.every((name, index) => entry[index] === name),
);
}
function OpenPreferencesButton() {
return (
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
);
}
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
document.getElementById("google-analytics-script")?.remove();
localStorage.clear();
for (const name of COOKIES) expireCookie(name);
analyticsWindow.dataLayer = undefined;
analyticsWindow.gtag = undefined;
analyticsWindow[GA_DISABLE_KEY] = undefined;
});
afterEach(() => {
jest.restoreAllMocks();
document.getElementById("google-analytics-script")?.remove();
for (const name of COOKIES) expireCookie(name);
});
describe("GoogleAnalytics: modo e teclas", () => {
it("path: outside production -> renders nothing and never touches analytics", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
const { container } = render(<GoogleAnalytics />);
await act(async () => {});
expect(container).toBeEmptyDOMElement();
expect(screen.queryByRole("dialog")).toBeNull();
expect(analyticsWindow.dataLayer).toBeUndefined();
});
it("path: Escape on the first-visit bar -> bar stays and no choice is stored", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
const accept = await screen.findByRole("button", { name: "Aceitar" });
accept.focus();
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog")).toBeVisible();
expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
expect(screen.queryByText(/Escolha atual/)).toBeNull();
});
it("path: another key on the reopened bar -> stays open", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await act(async () => {});
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.keyboard("x{Tab}");
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("path: reopened with a refusal stored -> the bar announces 'recusado'", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await act(async () => {});
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Escolha atual: recusado/,
);
});
});
describe("GoogleAnalytics: aceitar e recusar", () => {
it("path: reopened while already granted, 'Aceitar' only closes the bar", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
analyticsWindow.gtag = jest.fn();
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const gtag = analyticsWindow.gtag as jest.Mock;
const callsAfterMount = gtag.mock.calls.length;
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(gtag.mock.calls).toHaveLength(callsAfterMount);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
expect(opener).toHaveFocus();
});
it("path: granting again after a revocation -> bootstrap runs once, GA is re-enabled", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(false);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
// "default" e "js" só na primeira ativação; a configuração dos dois destinos roda a cada concessão
expect(commandsNamed("consent", "default")).toHaveLength(1);
expect(commandsNamed("js")).toHaveLength(1);
expect(commandsNamed("config", "G-QTT9QT0T3S")).toHaveLength(2);
expect(commandsNamed("config", "AW-18432694647")).toHaveLength(2);
});
it("path: first visit refused -> nothing is revoked because nothing was granted", async () => {
const user = userEvent.setup();
setCookie("_ga=keep-me; Path=/");
const cookieWrites = jest.spyOn(document, "cookie", "set");
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBeUndefined();
expect(analyticsWindow.dataLayer).toBeUndefined();
expect(cookieWrites).not.toHaveBeenCalled();
expect(document.cookie).toContain("_ga=keep-me");
});
it("path: revoking after gtag disappeared -> still persists, disables GA and does not throw", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
delete analyticsWindow.gtag;
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("GoogleAnalytics: cookies na revogação", () => {
it("path: only _ga and _ga_* are expired; look-alikes and other cookies stay", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
for (const name of COOKIES) setCookie(`${name}=1; Path=/`);
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
const remaining = document.cookie.split(";").map((c) => c.trim());
expect(remaining).toEqual(
expect.arrayContaining(["_gid=1", "_gat=1", "portfolio_session=1"]),
);
expect(remaining.some((c) => c.startsWith("_ga="))).toBe(false);
expect(remaining.some((c) => c.startsWith("_ga_ABC123="))).toBe(false);
});
it("path: no analytics cookie present -> revocation writes no cookie", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("portfolio_session=1; Path=/");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(cookieWrites).not.toHaveBeenCalled();
expect(document.cookie).toContain("portfolio_session=1");
});
it("path: host is not the site domain (localhost) -> expiry is written without a Domain attribute", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=1; Path=/");
render(
<>
<OpenPreferencesButton />
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(cookieWrites).toHaveBeenCalledTimes(1);
expect(cookieWrites.mock.calls[0][0]).toMatch(/^_ga=; Max-Age=0;/);
expect(cookieWrites.mock.calls[0][0]).not.toContain("Domain=");
});
});
describe("GoogleAnalytics: gtag, dataLayer e carregador", () => {
it("path: dataLayer already exists -> the same array receives the commands", async () => {
const user = userEvent.setup();
const existing: IArguments[] = [];
analyticsWindow.dataLayer = existing;
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(analyticsWindow.dataLayer).toBe(existing);
expect(existing.length).toBeGreaterThan(0);
expect(typeof analyticsWindow.gtag).toBe("function");
});
it("path: a gtag already exists -> it is reused instead of being replaced", async () => {
const user = userEvent.setup();
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(analyticsWindow.gtag).toBe(gtag);
expect(gtag).toHaveBeenCalledWith("js", expect.any(Date));
expect(analyticsWindow.dataLayer).toBeDefined();
});
it.each([
["denied", "denied"],
["no choice yet", null],
])(
"path: %s -> a loader left by next/script is removed",
async (_label, stored) => {
if (stored) localStorage.setItem(CONSENT_KEY, stored);
const loader = document.createElement("script");
loader.id = "google-analytics-script";
document.head.append(loader);
render(<GoogleAnalytics />);
await act(async () => {});
expect(document.getElementById("google-analytics-script")).toBeNull();
},
);
it("path: granted -> the loader stays", async () => {
localStorage.setItem(CONSENT_KEY, "granted");
const loader = document.createElement("script");
loader.id = "google-analytics-script";
document.head.append(loader);
render(<GoogleAnalytics />);
await act(async () => {});
expect(document.getElementById("google-analytics-script")).not.toBeNull();
});
});
describe("GoogleAnalytics: foco e falha de gravação", () => {
it("path: opener is not an HTML element (SVG) -> closing the bar has no element to refocus", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(
<>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: elemento SVG de teste só para receber o foco */}
<svg data-testid="svg-opener" tabIndex={0} />
<GoogleAnalytics />
</>,
);
await act(async () => {});
const svg = screen.getByTestId("svg-opener");
svg.focus();
expect(document.activeElement).toBe(svg);
act(() => openCookiePreferences());
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).not.toBe(svg);
});
it("path: acceptance fails to persist, then succeeds -> the alert goes away and analytics turns on", async () => {
const user = userEvent.setup();
const setItem = jest
.spyOn(Storage.prototype, "setItem")
.mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(
screen.getByRole("alert", { name: "Falha ao salvar o consentimento" }),
).toHaveTextContent(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
);
expect(screen.getByRole("dialog")).toBeVisible();
expect(analyticsWindow.dataLayer).toBeUndefined();
setItem.mockRestore();
await user.click(screen.getByRole("button", { name: "Aceitar" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
});
});
+96 -63
View File
@@ -1,6 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
jest.mock("next/script", () => ({
@@ -55,24 +56,49 @@ describe("GoogleAnalytics consent", () => {
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Aceitar analytics" }),
await screen.findByRole("button", { name: "Aceitar" }),
).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Usamos métricas de navegação do Google só se você permitir\./,
);
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ler política de privacidade" }),
screen.getByRole("link", { name: "Política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
expect(analyticsWindow.dataLayer).toBeUndefined();
});
it("keeps the prompt as a thin bottom bar and publishes its height while it is open", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
const bar = await screen.findByRole("dialog");
expect(bar).toHaveClass("fixed", "inset-x-0", "bottom-0");
expect(document.documentElement).toHaveAttribute("data-cookie-bar", "open");
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toMatch(/^\d+px$/);
// o link vem antes dos botões na ordem de Tab dentro da barra
expect(bar.querySelector("a, button")).toBe(
screen.getByRole("link", { name: "Política de privacidade" }),
);
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
});
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" }),
);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
@@ -116,36 +142,35 @@ describe("GoogleAnalytics consent", () => {
{ allow_ad_personalization_signals: false },
]);
expect(commands).toContainEqual(["set", "ads_data_redaction", true]);
expect(
screen.getByRole("button", { name: "Preferências de analytics" }),
).not.toHaveFocus();
// depois da escolha nada fica fixo na tela
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("persists refusal and keeps analytics absent after reload", async () => {
it("persists refusal and keeps analytics and the bar absent after reload", async () => {
const user = userEvent.setup();
const firstRender = render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
firstRender.unmount();
render(<GoogleAnalytics />);
const reloaded = render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Preferências de analytics" }),
).toBeVisible();
await act(async () => {});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(reloaded.container).toBeEmptyDOMElement();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
it("keeps preferences reachable and fully revokes analytics only", async () => {
it("reopens from the footer link and fully revokes analytics only", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=main; Path=/");
@@ -153,13 +178,26 @@ describe("GoogleAnalytics consent", () => {
setCookie("portfolio_session=keep; Path=/");
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(<GoogleAnalytics />);
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Escolha atual: permitido/,
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(screen.getByRole("button", { name: "Aceitar" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Recusar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
@@ -179,33 +217,40 @@ describe("GoogleAnalytics consent", () => {
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
const preferencesButton = screen.getByRole("button", {
name: "Preferências de analytics",
});
expect(preferencesButton).toBeVisible();
expect(preferencesButton).toHaveFocus();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(opener).toHaveFocus();
});
it("restores focus after closing or applying opened preferences", async () => {
it("restores focus after closing with Escape 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);
expect(
screen.getByRole("link", { name: "Ler política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
await user.click(
screen.getByRole("button", { name: "Fechar preferências" }),
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
expect(preferencesButton).toHaveFocus();
await act(async () => {});
await user.click(preferencesButton);
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
expect(preferencesButton).toHaveFocus();
const opener = screen.getByRole("button", {
name: "Preferências de cookies",
});
await user.click(opener);
expect(
screen.getByRole("link", { name: "Política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(opener).toHaveFocus();
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
await user.click(opener);
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(opener).toHaveFocus();
});
it("fails closed when local storage is unavailable", async () => {
@@ -218,9 +263,7 @@ describe("GoogleAnalytics consent", () => {
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
await waitFor(() => {
expect(
@@ -238,10 +281,8 @@ describe("GoogleAnalytics consent", () => {
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" }));
act(() => openCookiePreferences());
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(
screen.getByRole("alert", {
@@ -251,9 +292,7 @@ describe("GoogleAnalytics consent", () => {
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
expect(analyticsWindow[GA_DISABLE_KEY]).not.toBe(true);
expect(
screen.getByRole("button", { name: "Revogar analytics" }),
).toBeVisible();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
});
it("reports when refusal cannot be persisted", async () => {
@@ -263,9 +302,7 @@ describe("GoogleAnalytics consent", () => {
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
await user.click(await screen.findByRole("button", { name: "Recusar" }));
expect(
screen.getByRole("alert", {
@@ -274,11 +311,7 @@ describe("GoogleAnalytics consent", () => {
).toHaveTextContent(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
expect(
screen.getByRole("button", { name: "Recusar analytics" }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Preferências de analytics" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toBeVisible();
});
});
+108 -144
View File
@@ -7,11 +7,13 @@ import {
CONSENT_STORAGE_KEY,
GA_DISABLE_KEY,
GA_MEASUREMENT_ID,
GOOGLE_ADS_ID,
type Gtag,
OPEN_COOKIE_PREFERENCES_EVENT,
readConsent,
} from "@/lib/analytics";
import "./conversion.css";
const GOOGLE_ADS_ID = "AW-18432694647";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
@@ -76,17 +78,17 @@ function expireAnalyticsCookies() {
}
}
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)]";
const barButtonClassName =
"cv-btn inline-flex min-h-10 min-w-11 items-center justify-center border px-[10px] py-sp-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] motion-reduce:transition-none";
function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const configuredRef = useRef(false);
const preferencesButtonRef = useRef<HTMLButtonElement>(null);
const barRef = useRef<HTMLElement>(null);
const acceptRef = useRef<HTMLButtonElement>(null);
const openerRef = useRef<HTMLElement | null>(null);
const enableAnalytics = useCallback(() => {
const gtag = getGtag();
@@ -123,12 +125,56 @@ function GoogleAnalyticsConsent() {
}
}, [consent]);
// O link "Preferências de cookies" do rodapé reabre a barra; quem pediu recebe o foco de volta ao fechar.
useEffect(() => {
const reopen = () => {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setPreferencesOpen(true);
};
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
return () =>
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, reopen);
}, []);
const barOpen = consent === null || preferencesOpen;
// Enquanto a barra está na tela, publica a altura dela para o botão flutuante do WhatsApp subir.
useEffect(() => {
const bar = barRef.current;
if (!barOpen || !bar) return;
const root = document.documentElement;
const publish = () =>
root.style.setProperty("--cookie-bar-height", `${bar.offsetHeight}px`);
publish();
root.setAttribute("data-cookie-bar", "open");
const observer = new ResizeObserver(publish);
observer.observe(bar);
return () => {
observer.disconnect();
root.style.removeProperty("--cookie-bar-height");
root.removeAttribute("data-cookie-bar");
};
}, [barOpen]);
// Reaberta pelo rodapé, a barra está no começo do documento: leva o foco para ela.
useEffect(() => {
if (preferencesOpen) acceptRef.current?.focus();
}, [preferencesOpen]);
function closePreferences() {
setPreferencesOpen(false);
preferencesButtonRef.current?.focus();
openerRef.current?.focus();
openerRef.current = null;
}
function acceptAnalytics() {
if (consent === "granted") {
closePreferences();
return;
}
if (!persistConsent("granted")) {
setConsentError(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
@@ -142,92 +188,27 @@ function GoogleAnalyticsConsent() {
}
function denyAnalytics() {
const revoking = consent === "granted";
if (!persistConsent("denied")) {
setConsentError(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
revoking
? "Não foi possível salvar a revogação. Tente novamente para concluir."
: "Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
setConsent("denied");
closePreferences();
}
function revokeAnalytics() {
if (!persistConsent("denied")) {
setConsentError(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
return;
if (revoking) {
window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies();
}
setConsentError(null);
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 de navegação com a tag compartilhada
do Google Analytics e Google Ads. Cookies de publicidade e
personalização continuam desativados.
</p>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
>
{consentError}
</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>
<a
className="ds-link ds-link-accent mt-sp-3 inline-block"
href="/privacidade"
>
Ler política de privacidade
</a>
</section>
);
}
return (
<>
{consent === "granted" && (
@@ -238,82 +219,65 @@ function GoogleAnalyticsConsent() {
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)}
>
<span className="max-sm:hidden">Preferências de analytics</span>
<span aria-hidden="true" className="sm:hidden">
Cookies
</span>
</button>
{preferencesOpen && (
{barOpen && (
<section
id="analytics-preferences"
ref={barRef}
role="dialog"
aria-modal="false"
aria-labelledby="analytics-preferences-title"
aria-describedby="analytics-preferences-description"
className={panelClassName}
aria-label="Cookies e privacidade"
aria-describedby="analytics-consent-description"
onKeyDown={(event) => {
if (event.key === "Escape" && preferencesOpen) closePreferences();
}}
className="fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] sm:px-sp-6"
>
<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"}.
Cookies de publicidade e personalização permanecem negados.
</p>
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-sm"
>
Usamos métricas de navegação do Google só se você permitir.{" "}
<a
className="whitespace-nowrap underline underline-offset-2 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
href="/privacidade"
>
Política de privacidade
</a>
{preferencesOpen && consent && (
<span className="sr-only">
{" "}
Escolha atual:{" "}
{consent === "granted" ? "permitido" : "recusado"}.
</span>
)}
</p>
<div className="flex shrink-0 gap-sp-2">
<button
type="button"
ref={acceptRef}
className={`${barButtonClassName} border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] hover:bg-white`}
onClick={acceptAnalytics}
>
Aceitar
</button>
<button
type="button"
className={`${barButtonClassName} border-white/45 bg-transparent text-white hover:border-white`}
onClick={denyAnalytics}
>
Recusar
</button>
</div>
</div>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[13px] leading-5 text-[#ff9d94]"
>
{consentError}
</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>
<a
className="ds-link ds-link-accent mt-sp-3 inline-block"
href="/privacidade"
>
Ler política de privacidade
</a>
</section>
)}
</>
@@ -0,0 +1,67 @@
/**
* @jest-environment-options {"url": "https://www.evandro.dev.br/"}
*/
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { openCookiePreferences } from "@/lib/analytics";
import GoogleAnalytics from "./GoogleAnalytics";
/**
* Caminho de GoogleAnalytics (expireAnalyticsCookies) quando o site roda em um subdomínio de evandro.dev.br:
* o cookie _ga é expirado no host e também no domínio raiz, cada um com seu atributo Domain.
*/
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it("path: host is a subdomain of the site -> expires _ga on the path, on the host and on the root domain", async () => {
const user = userEvent.setup();
expect(window.location.hostname).toBe("www.evandro.dev.br");
setCookie("_ga=1; Path=/");
render(
<>
<button type="button" onClick={openCookiePreferences}>
Preferências de cookies
</button>
<GoogleAnalytics />
</>,
);
await screen.findByTestId("google-analytics-script");
const cookieWrites = jest.spyOn(document, "cookie", "set");
await user.click(
screen.getByRole("button", { name: "Preferências de cookies" }),
);
await act(async () => {});
await user.click(screen.getByRole("button", { name: "Recusar" }));
const writes = cookieWrites.mock.calls.map(([value]) => String(value));
expect(writes).toHaveLength(3);
expect(writes[0]).toMatch(/^_ga=; Max-Age=0;/);
expect(writes[0]).not.toContain("Domain=");
expect(writes[1]).toMatch(/; Domain=www\.evandro\.dev\.br$/);
expect(writes[2]).toMatch(/; Domain=evandro\.dev\.br$/);
});
+13 -11
View File
@@ -32,17 +32,19 @@ describe("Header", () => {
within(navigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/#modalities",
"/cases",
"/#process",
"/#about",
"/#experience",
]);
).toEqual(["/#projects", "/#process", "/#about", "/cases", "/servicos"]);
expect(
within(navigation)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(["Projetos", "Como trabalho", "Sobre", "Cases", "Serviços"]);
expect(
within(navigation).queryByRole("link", { name: "Experiência" }),
).not.toBeInTheDocument();
const contactLink = screen
.getAllByRole("link")
.find((link) => link.getAttribute("href") === "/#contact");
expect(contactLink).toHaveAccessibleName();
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(
screen.queryByRole("link", { name: /currículo|download cv/i }),
).not.toBeInTheDocument();
@@ -75,11 +77,11 @@ describe("Header", () => {
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/#modalities",
"/cases",
"/#projects",
"/#process",
"/#about",
"/#experience",
"/cases",
"/servicos",
"/#contact",
]);
expect(
+121
View File
@@ -0,0 +1,121 @@
import { fireEvent, render, screen } from "@testing-library/react";
import Hero from "./Hero";
/**
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): statItems() (sem stats, sem anos, zero anos, com anos),
* links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
*/
jest.mock("./cinematic/HeroField", () => ({
__esModule: true,
default: () => <div data-testid="hero-field" />,
}));
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
window.gtag = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
describe("Hero: números de experiência (statItems)", () => {
it.each([
["no stats at all", undefined],
["stats without years", {}],
["zero years", { years: 0 }],
])("path: %s -> no figures block", (_label, stats) => {
const { container } = render(<Hero stats={stats} />);
expect(container.querySelector("dl")).toBeNull();
expect(screen.queryByText("anos de experiência")).toBeNull();
});
it("path: years given -> one figure with the number and its label", () => {
const { container } = render(<Hero stats={{ years: 7 }} />);
const items = container.querySelectorAll("dl > div");
expect(items).toHaveLength(1);
expect(items[0].querySelector("dd")).toHaveTextContent("7");
expect(items[0].querySelector("dt")).toHaveTextContent(
"anos de experiência",
);
});
});
describe("Hero: links sociais", () => {
it("path: LinkedIn (http link) -> new tab with noopener and a social_click", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "LinkedIn" });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"social_click",
expect.objectContaining({ network: "linkedin" }),
);
});
it("path: e-mail (mailto link) -> same tab, no rel, and an email_click from the hero", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "Email" });
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "hero" }),
);
});
});
describe("Hero: faixa de clientes e trabalho em destaque", () => {
it("path: clients given -> the strip links each client to its case", () => {
render(<Hero clients={[{ id: "lavacao-047", name: "Lavação 047" }]} />);
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Lavação 047" })).toHaveAttribute(
"href",
"/cases/lavacao-047",
);
});
it("path: no clients -> no strip", () => {
render(<Hero />);
expect(screen.queryByText("Já entregue para:")).toBeNull();
});
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
render(<Hero workUrl={{ text: "lavacao.example.com", secure: true }} />);
expect(screen.getByTestId("hero-work")).toHaveTextContent(
"lavacao.example.com",
);
});
it("path: workUrl omitted -> the browser bar shows no address", () => {
render(<Hero />);
expect(screen.getByTestId("hero-work")).not.toHaveTextContent(".com");
});
it("path: the main button carries the id that hides the floating WhatsApp", () => {
render(<Hero />);
expect(
screen.getByRole("link", { name: /Conversar sobre meu projeto/ }),
).toHaveAttribute("id", "hero-contact-cta");
});
});
+59 -38
View File
@@ -1,7 +1,14 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero, { roundedRepoFloor } from "./Hero";
import Hero from "./Hero";
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
{ id: "lavacao-047", name: "Lavação 047" },
{ id: "sc-plus", name: "SC Plus Aquicultura" },
{ id: "as-servicos", name: "AS Serviços" },
];
jest.mock("motion/react", () => {
const React = require("react");
@@ -60,61 +67,75 @@ describe("Hero", () => {
);
});
it("keeps social destinations with isolated external tabs", () => {
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
render(<Hero />);
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
"href",
"https://github.com/evandrodevbr",
);
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
"href",
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
"https://www.linkedin.com/in/evandrodevbr/",
);
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
"href",
"mailto:[email protected]",
);
for (const name of ["GitHub", "LinkedIn"]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
const linkedin = screen.getByRole("link", { name: "LinkedIn" });
expect(linkedin).toHaveAttribute("target", "_blank");
expect(linkedin).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
}
});
it("shows the real GitHub repo count with the public/private breakdown", () => {
render(<Hero stats={{ repos: { total: 68, public: 25, private: 43 } }} />);
expect(screen.getByText("projetos desenvolvidos")).toBeInTheDocument();
expect(screen.getByText("Mais de")).toBeInTheDocument();
expect(screen.getByText("65")).toBeInTheDocument();
expect(screen.getByText("25 públicos · 43 privados")).toBeInTheDocument();
expect(screen.queryByText(/projetos publicados/i)).not.toBeInTheDocument();
expect(screen.queryByText(/demonstração online/i)).not.toBeInTheDocument();
});
it("rounds the repo count down so 'mais de' is always true", () => {
expect(roundedRepoFloor(68)).toBe(65);
expect(roundedRepoFloor(66)).toBe(65);
expect(roundedRepoFloor(65)).toBe(60);
expect(roundedRepoFloor(6)).toBe(5);
expect(roundedRepoFloor(5)).toBe(0);
});
it("hides the repo count while the total is too small to round", () => {
render(<Hero stats={{ repos: { total: 4, public: 2, private: 2 } }} />);
it("states who the work is for and how the contact works", () => {
render(<Hero />);
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
screen.getByText(
"Para empresas que cresceram além das planilhas e dos sistemas prontos: sites, sistemas web, aplicativos e integrações, com contato direto com quem desenvolve.",
),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: /conversar/i })).toHaveAttribute(
"id",
"hero-contact-cta",
);
});
it("omits the repo count when the GitHub lookup is unavailable", () => {
it("shows only the years of experience, with no repository numbers", () => {
render(<Hero stats={{ years: 5 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull();
expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull();
});
it("lists the delivered clients, each linking to its case", () => {
render(<Hero clients={clients} />);
expect(screen.getByText("Já entregue para:")).toBeInTheDocument();
for (const { id, name } of clients) {
expect(screen.getByRole("link", { name })).toHaveAttribute(
"href",
`/cases/${id}`,
);
}
});
it("renders the work column with lazy posters that stay out of small screens", () => {
render(<Hero clients={clients} />);
const work = screen.getByTestId("hero-work");
expect(work).toHaveClass("hidden", "lg:block");
const images = work.querySelectorAll("img");
expect(images).toHaveLength(2);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
}
expect(images[0]).toHaveAttribute(
"src",
"/videos/lavacao-047/desktop.webp",
);
expect(images[1]).toHaveAttribute("src", "/videos/as-servicos/mobile.webp");
});
});
+46 -54
View File
@@ -1,61 +1,42 @@
"use client";
import Link from "next/link";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
const SOCIAL_LINKS = [
{
href: "https://github.com/evandrodevbr",
label: "GitHub",
},
{
href: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
href: LINKEDIN_URL,
label: "LinkedIn",
onClick: () => trackSocialClick({ network: "linkedin" }),
},
{
href: "mailto:[email protected]",
href: `mailto:${CONTACT_EMAIL}`,
label: "Email",
onClick: () => trackEmailClick({ placement: "hero" }),
},
];
/**
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json;
* repos vem da API do GitHub (lib/github-stats.ts) e some quando nem a consulta nem o último retrato estão disponíveis.
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json.
*/
export type HeroStats = {
years?: number;
repos?: { total: number; public: number; private: number };
};
type StatItem = {
value: string;
label: string;
prefix?: string;
detail?: string;
};
/** "Mais de N" só é verdade se N for estritamente menor que o total: arredonda para baixo, em múltiplos de 5. */
export function roundedRepoFloor(total: number) {
return Math.floor((total - 1) / 5) * 5;
}
function statItems(stats?: HeroStats): StatItem[] {
if (!stats) return [];
const { repos } = stats;
const repoFloor = repos ? roundedRepoFloor(repos.total) : 0;
const items: Array<StatItem | null> = [
stats.years
? { value: String(stats.years), label: "anos de experiência" }
: null,
repos && repoFloor > 0
? {
prefix: "Mais de",
value: String(repoFloor),
label: "projetos desenvolvidos",
detail: `${repos.public} públicos · ${repos.private} privados`,
}
: null,
];
return items.filter((item): item is StatItem => item !== null);
if (!stats?.years) return [];
return [{ value: String(stats.years), label: "anos de experiência" }];
}
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
@@ -75,7 +56,16 @@ const CAPTIONS = [
["Os dados", "Suas informações guardadas, organizadas e seguras."],
] as const;
export default function Hero({ stats }: { stats?: HeroStats }) {
export default function Hero({
stats,
clients,
workUrl,
}: {
stats?: HeroStats;
clients?: ClientLink[];
/** Endereço mostrado na barra do navegador de HeroWork (o do site da Lavação 047). */
workUrl?: HeroWorkUrl;
}) {
const items = statItems(stats);
return (
@@ -97,13 +87,15 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
))}
</ol>
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)] lg:px-sp-12">
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-mono text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
{/* No desktop o título para antes da coluna HeroWork (largura da coluna: clamp(300px,24vw,400px)). */}
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw,calc(100%-clamp(300px,24vw,400px)-24px))] text-[clamp(38px,min(6vw,9svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
{TITLE.map((word, index) => (
<Fragment key={word}>
<span
@@ -115,13 +107,15 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
</Fragment>
))}
</h1>
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
entendimento do negócio à arquitetura e à entrega em produção.
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(40em,calc(100vw-clamp(300px,24vw,400px)-9rem))]">
Para empresas que cresceram além das planilhas e dos sistemas
prontos: sites, sistemas web, aplicativos e integrações, com contato
direto com quem desenvolve.
</p>
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<Link
id={HERO_CTA_ID}
href="/#contact"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
@@ -139,33 +133,30 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
</Link>
<Link
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
>
Ver projetos entregues
</Link>
</div>
<ClientStrip
clients={clients}
className="hero-in mt-sp-4 [--d:.75s] lg:max-w-[min(44rem,calc(100vw-clamp(300px,24vw,400px)-9rem))]"
/>
<HeroWork url={workUrl} />
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(({ prefix, value, label, detail }) => (
{items.map(({ value, label }) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
{detail && (
<span className="block text-[12px] text-[rgba(244,242,237,.55)]">
{detail}
</span>
)}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{prefix && (
<span className="mr-sp-2 text-[13px] font-normal tracking-normal text-[rgba(244,242,237,.64)]">
{prefix}
</span>
)}
{value}
</dd>
</div>
@@ -198,6 +189,7 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
? "noopener noreferrer"
: undefined
}
onClick={link.onClick}
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
>
{link.label}
@@ -211,7 +203,7 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
+54
View File
@@ -0,0 +1,54 @@
import { getShowcaseAssets } from "@/lib/project-showcase";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
import "./showcase/showcase.css";
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "lavacao-047";
const MOBILE_PROJECT = "as-servicos";
/**
* Trabalho real ao lado do título, só em telas de 1024 px ou mais: o pôster do site da Lavação 047 no navegador
* e o do aplicativo da AS Serviços no celular. A coluna inteira fica escondida (display: none) abaixo disso,
* então as imagens (lazy) nem são baixadas no celular, e elas são menores que o título para o H1 seguir como maior elemento.
*/
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
return (
<div
data-testid="hero-work"
className="hero-in pointer-events-none absolute right-sp-12 bottom-sp-8 hidden w-[clamp(300px,24vw,400px)] [--d:.8s] lg:block"
>
<BrowserFrame url={url} className="w-[88%]">
{/* biome-ignore lint/performance/noImgElement: pôster de 1440 px já otimizado em /public/videos; next/image pediria o otimizador para um arquivo menor que o necessário */}
<img
src={desktop.poster}
alt="Site da Lavação 047 aberto no computador"
width={desktop.width}
height={desktop.height}
loading="lazy"
decoding="async"
fetchPriority="low"
className="block h-auto w-full"
/>
</BrowserFrame>
<PhoneFrame className="absolute right-0 bottom-[-12%] w-[34%]">
{/* biome-ignore lint/performance/noImgElement: mesmo motivo do pôster desktop */}
<img
src={phone.poster}
alt="Site da AS Serviços aberto no celular"
width={phone.width}
height={phone.height}
loading="lazy"
decoding="async"
fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top"
/>
</PhoneFrame>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LighthouseMetrics from "./LighthouseMetrics";
it("updates the inspection criteria while preserving keyboard navigation", async () => {
const user = userEvent.setup();
render(<LighthouseMetrics />);
const report = screen.getByRole("region", { name: "Inspeção de qualidade" });
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
const accessibility = screen.getByRole("radio", { name: /Acessibilidade/ });
await user.click(accessibility);
expect(accessibility).toHaveFocus();
expect(within(report).getByRole("list")).toHaveTextContent(/teclado/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Boas práticas/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/consentimento/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /SEO/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/Sitemap/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
});
-337
View File
@@ -1,337 +0,0 @@
"use client";
import { useState } from "react";
const METRICS = [
{
id: "performance",
label: "Performance",
code: "LCP / INP / CLS",
title: "O essencial chega primeiro.",
description:
"O carregamento começa pelo que importa. Imagens, fontes e JavaScript precisam trabalhar a favor da experiência, não competir com ela.",
checks: [
"Conteúdo principal com prioridade de carregamento",
"Dimensões reservadas para evitar saltos de layout",
"JavaScript carregado onde faz diferença",
],
caption: "Em foco: o conteúdo principal e a estabilidade da página.",
},
{
id: "accessibility",
label: "Acessibilidade",
code: "TECLADO / FOCO / SEMÂNTICA",
title: "A interface inclui mais pessoas.",
description:
"Uma boa interface não exige um jeito único de navegar. Teclado, leitores de tela e preferências de movimento fazem parte do projeto.",
checks: [
"Foco visível e navegação por teclado",
"Controles com nomes e estrutura semântica",
"Contraste legível e movimento reduzido",
],
caption:
"Em foco: controles reconhecíveis e um caminho claro pelo teclado.",
},
{
id: "practices",
label: "Boas práticas",
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
title: "Confiança também se constrói.",
description:
"Cuidar da entrega também é cuidar dos dados, das dependências e dos limites do navegador. Segurança não se resume a uma nota.",
checks: [
"Conexões seguras e políticas do navegador",
"Analytics condicionado ao consentimento",
"Dependências verificadas e atualizadas",
],
caption: "Em foco: a conexão e os limites de acesso aos dados.",
},
{
id: "seo",
label: "SEO",
code: "HTML / METADATA / INDEXAÇÃO",
title: "Conteúdo pronto para ser encontrado.",
description:
"Estrutura antes de promessas. Conteúdo claro, títulos coerentes e dados estruturados ajudam pessoas e buscadores a entender cada página.",
checks: [
"Títulos, descrições e URLs canônicas",
"HTML semântico e conteúdo rastreável",
"Sitemap e dados estruturados factuais",
],
caption: "Em foco: a estrutura que dá contexto ao conteúdo.",
},
];
const BLUEPRINT = (
<svg
className="quality-blueprint"
viewBox="0 0 640 440"
fill="none"
aria-hidden="true"
focusable="false"
>
<g className="quality-dimensions">
<path d="M64 68V48m512 20V48M64 58h512M44 86H24m20 284H24M34 86v284" />
<path d="m60 54 8 8m504-8 8 8M30 82l8 8m-8 276 8 8" />
<text x="304" y="47">
RESPONSIVE
</text>
<path d="M64 390v18m512-18v18M64 400h180m152 0h180" />
<text x="272" y="405">
WEB / BLUEPRINT
</text>
</g>
<rect
className="quality-browser-frame"
x="64"
y="86"
width="512"
height="284"
rx="8"
/>
<path className="quality-wire" d="M64 123h512" />
<g className="quality-browser-dots">
<circle cx="83" cy="105" r="3" />
<circle cx="95" cy="105" r="3" />
<circle cx="107" cy="105" r="3" />
</g>
<rect
className="quality-wire"
x="142"
y="96"
width="350"
height="18"
rx="3"
/>
<text className="quality-address" x="158" y="109">
evandro.dev.br
</text>
<path className="quality-wire" d="M539 100h15m-15 5h15m-15 5h15" />
<text className="quality-browser-heading" x="96" y="176">
Engenharia
</text>
<text className="quality-browser-heading" x="96" y="203">
que funciona.
</text>
<path className="quality-wire" d="M96 222h170M96 230h130" />
<rect
className="quality-browser-button"
x="96"
y="254"
width="142"
height="32"
rx="2"
/>
<text className="quality-browser-button-label" x="112" y="274">
EXPLORAR PROJETO
</text>
<rect
className="quality-wire"
x="330"
y="144"
width="216"
height="142"
rx="3"
/>
<g className="quality-wire">
<path d="m438 164 58 33v55l-58 33-58-33v-55zM380 197l58 33 58-33m-58 33v55m0-121v66" />
<path d="m409 181 58 33v55m-58 0v-55l58-33" />
<path d="M96 306h450M96 324h113m-113 10h80m84-10h113m-113 10h80m84-10h122m-122 10h80" />
</g>
<g className="quality-layer" data-layer="performance">
<rect
className="quality-highlight"
x="318"
y="133"
width="240"
height="165"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M558 216h43V59h-91" />
<circle cx="558" cy="216" r="4" />
<text x="393" y="63">
LCP / CONTEÚDO
</text>
<path d="M330 299v12m216-12v12m-216-6h216" />
<rect
className="quality-annotation"
x="368"
y="311"
width="154"
height="18"
/>
<text x="376" y="323">
ESPAÇO RESERVADO
</text>
</g>
<g className="quality-layer" data-layer="accessibility">
<rect
className="quality-highlight quality-focus-frame"
x="88"
y="246"
width="158"
height="48"
rx="5"
/>
<path className="quality-trace" pathLength="1" d="M88 270H50v149h20" />
<circle cx="88" cy="270" r="4" />
<text x="79" y="424">
TAB / FOCO VISÍVEL
</text>
<rect x="260" y="259" width="45" height="24" rx="3" />
<text x="270" y="275">
TAB
</text>
</g>
<g className="quality-layer" data-layer="practices">
<rect
className="quality-highlight"
x="134"
y="91"
width="366"
height="28"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M500 105h101v314h-90" />
<circle cx="500" cy="105" r="4" />
<path
className="quality-highlight"
d="m499 220 36 13v32c0 25-36 41-36 41s-36-16-36-41v-32z"
/>
<rect x="487" y="255" width="24" height="20" rx="2" />
<path d="M491 255v-7a8 8 0 0 1 16 0v7m-8 6v7" />
<text x="290" y="424">
HTTPS / CONSENTIMENTO
</text>
</g>
<g className="quality-layer" data-layer="seo">
<rect
className="quality-highlight"
x="85"
y="144"
width="224"
height="95"
rx="3"
/>
<path className="quality-trace" pathLength="1" d="M85 159H48V58h135" />
<circle cx="85" cy="159" r="4" />
<text x="74" y="49">
HTML / SEMÂNTICA
</text>
<text x="99" y="140">
&lt;h1&gt;
</text>
<path d="M331 317h213v29H331z" />
<text x="350" y="337">
&lt;article&gt; / &lt;nav&gt;
</text>
</g>
</svg>
);
export default function LighthouseMetrics() {
const [active, setActive] = useState(METRICS[0]);
return (
<section
id="lighthouse"
className="ds-section ds-section-dark quality-lab"
aria-labelledby="quality-title"
data-inspection={active.id}
>
<div className="ds-section-shell">
<div className="quality-intro">
<header className="quality-copy">
<p className="ds-eyebrow">Critérios de entrega</p>
<h2 id="quality-title" className="ds-section-title">
Engenharia de ponta a ponta.
<span>Qualidade em cada camada.</span>
</h2>
<p>
Sistemas precisam mais do que uma boa aparência. Explore as
decisões que tornam uma interface rápida, acessível, confiável e
fácil de encontrar.
</p>
<a className="ds-link ds-link-accent" href="/#contact">
Conversar sobre a qualidade do seu projeto
</a>
</header>
<figure className="quality-visual bg-grid-light">
<div className="quality-visual-label">
<span>Laboratório de qualidade</span>
<span>Modelo interativo</span>
</div>
{BLUEPRINT}
<figcaption>
{METRICS.map((metric) => (
<span key={metric.id} aria-hidden={active.id !== metric.id}>
{metric.caption}
</span>
))}
</figcaption>
</figure>
</div>
<fieldset className="quality-controls" aria-describedby="quality-hint">
<legend className="sr-only">Escolha um aspecto da qualidade</legend>
{METRICS.map((metric) => (
<label key={metric.id} className="quality-control">
<input
type="radio"
name="quality-pillar"
value={metric.id}
checked={active.id === metric.id}
onChange={() => setActive(metric)}
aria-controls="quality-report"
/>
<span className="quality-control-content">
<span className="quality-control-name">{metric.label}</span>
<span className="quality-control-target">Ver critérios</span>
</span>
</label>
))}
</fieldset>
<p id="quality-hint" className="quality-hint">
Selecione um pilar para explorar. No teclado, use as setas.
</p>
<section
id="quality-report"
className="quality-report-stack"
aria-label="Inspeção de qualidade"
aria-live="polite"
aria-atomic="true"
>
{METRICS.map((metric) => (
<div
key={metric.id}
className="quality-report"
aria-hidden={active.id !== metric.id}
>
<div className="quality-report-copy">
<p className="quality-code">{metric.code}</p>
<h3>{metric.title}</h3>
<p>{metric.description}</p>
</div>
<ul>
{metric.checks.map((check) => (
<li key={check}>
<span aria-hidden="true">/</span>
{check}
</li>
))}
</ul>
</div>
))}
</section>
<p className="quality-disclaimer">
Exemplos de critérios avaliados conforme o projeto. Este painel não
representa uma auditoria, certificação ou garantia de resultado.
</p>
</div>
</section>
);
}
+57 -134
View File
@@ -1,47 +1,12 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import { isClientProject } from "@/lib/proof";
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>
@@ -51,111 +16,69 @@ jest.mock("./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",
const providerProjects = jest.fn();
jest.mock("./showcase/ShowcaseProvider", () => ({
ShowcaseProvider: ({
children,
projects,
}: {
children: ReactNode;
projects: PortfolioProjectView[];
}) => {
providerProjects(projects);
return <div data-testid="showcase-provider">{children}</div>;
},
{
...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,
},
];
}));
jest.mock("./showcase/ProjectReel", () => ({
ProjectReel: () => <div data-testid="project-reel" />,
}));
// A página entrega só os projetos de cliente; aqui usamos os reais do portfólio, com uma demo indisponível de propósito
const clientProjects: PortfolioProjectView[] = getFeaturedProjects(null)
.filter(isClientProject)
.map((project, index) => ({
...project,
demoAvailability: index === 0 ? ("unavailable" as const) : "available",
}));
describe("Projects", () => {
it("renders demo actions according to availability", () => {
render(<Projects projects={projects} />);
beforeEach(() => providerProjects.mockClear());
expect(
screen.getByRole("heading", { name: "Demo disponível" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
"href",
"https://available.example",
);
it("presents the client projects once, through the reel only", () => {
const { container } = render(<Projects projects={clientProjects} />);
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();
expect(clientProjects.length).toBeGreaterThan(0);
expect(screen.getByTestId("project-reel")).toBeInTheDocument();
expect(providerProjects).toHaveBeenCalledWith(clientProjects);
// sem grade de cards: nem artigos, nem capas provisórias
expect(container.querySelector("article")).toBeNull();
expect(container.querySelector(".ds-cover, .ds-cover-fallback")).toBeNull();
expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument();
});
it("keeps project evidence visible when the demo is unavailable", () => {
render(<Projects projects={projects} />);
it("drops the availability badge, the code link and every GitHub link", () => {
const { container } = render(<Projects projects={clientProjects} />);
const card = screen
.getByRole("heading", { name: "Demo indisponível" })
.closest("article");
expect(card).not.toBeNull();
const unavailableCard = within(card as HTMLElement);
expect(
screen.queryByText(/Demo indisponível|indisponível na última/i),
).toBeNull();
expect(screen.queryByText(/Ver codigo|Ver código/i)).toBeNull();
expect(screen.queryByText(/Abrir projeto/i)).toBeNull();
for (const link of container.querySelectorAll("a")) {
expect(link.getAttribute("href") ?? "").not.toMatch(/github\.com/);
}
});
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();
it("ends with a secondary link to all the cases", () => {
render(<Projects projects={clientProjects} />);
const link = screen.getByRole("link", { name: "Ver todos os cases" });
expect(link).toHaveAttribute("href", "/cases");
// secundário: contorno, sem o fundo verde-limão do contato
expect(link.className).toContain("border");
expect(link.className).not.toContain("bg-[var(--slash)]");
expect(screen.getAllByRole("link")).toEqual([link]);
});
});
+8 -167
View File
@@ -1,194 +1,35 @@
"use client";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import Link from "next/link";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { caseStudies } from "@/lib/site-content";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { ProjectCover } from "./ProjectCover";
import { secondaryButtonDark } from "./button-styles";
import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel";
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
/** Vitrine única da home: recebe só projetos de cliente (o filtro vem de app/page.tsx). */
export default function Projects({
projects,
}: {
projects: PortfolioProjectView[];
}) {
const shouldReduceMotion = useReducedMotion();
const cardVariants: Variants = {
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
},
};
return (
<Section
id="projects"
eyebrow="Projetos"
title="Veja o que já saiu do papel."
description="Sistemas e aplicações com contexto, decisões técnicas e limites documentados. Explore os cases para entender o problema e a solução, além da interface."
description="Projetos feitos para empresas. Abra um case para entender o problema, a solução e as decisões por trás da interface."
dark
disableChildrenAnimation
>
<ShowcaseProvider projects={projects}>
<div className="ds-grid-fluid">
{projects.map((project) => {
const caseStudy = caseStudies.find(
(entry) => entry.id === project.id,
);
return (
<motion.article
key={project.id}
className="ds-card h-full"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: "some" }}
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<ProjectCover project={project} />
<div className="flex flex-wrap items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
)}
</div>
<MagneticButton
as="span"
className="ds-tag"
strength={0.15}
wrapperClassName="shrink-0 max-sm:ml-auto"
>
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">
{caseStudy?.summary ?? project.description}
</p>
{caseStudy && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">O desafio</p>
<p className="ds-copy">{caseStudy.impact}</p>
</div>
)}
{caseStudy?.role && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">Minha participação</p>
<p className="ds-copy">{caseStudy.role}</p>
</div>
)}
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
<div
className="mt-sp-6 flex flex-wrap gap-sp-3"
data-reel="top"
>
{caseStudy && (
<MagneticButton
as="a"
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
strength={0.2}
>
Entender a solução
</MagneticButton>
)}
{project.githubUrl && (
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
>
Ver codigo
</MagneticButton>
)}
{project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown") && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
{project.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
)}
</MagneticButton>
)}
{project.demoAvailability === "unavailable" && (
<span
className="ds-link ds-link-accent"
aria-disabled="true"
>
Demo indisponível na última verificação
</span>
)}
</div>
</motion.article>
);
})}
</div>
<ProjectReel />
</ShowcaseProvider>
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-accent"
strength={0.2}
>
Explorar todos os cases
</MagneticButton>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver servicos relacionados
</MagneticButton>
<div className="mt-sp-8" data-reel-stagger="left">
<Link href="/cases" className={secondaryButtonDark}>
Ver todos os cases
</Link>
</div>
</Section>
);
-92
View File
@@ -1,92 +0,0 @@
"use client";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
import { Section } from "./Section";
export default function Skills({
categories,
}: {
categories: PortfolioSkillCategory[];
}) {
const shouldReduceMotion = useReducedMotion();
const gridVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.06,
staggerChildren: 0.08,
},
},
};
const cardVariants: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
visible: {
opacity: 1,
y: 0,
clipPath: "inset(0% 0 0 0)",
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
},
};
return (
<Section
id="skills"
eyebrow="Core stack"
title="Next.js, Tauri, Rust e stack para produtos críticos"
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integrações complexas."
dark
disableChildrenAnimation
>
<motion.div
className="ds-grid-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
{categories.map((category) => (
<motion.article
key={category.category}
className="ds-card h-full"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 40px rgba(0, 0, 0, 0.35)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<h3 className="ds-card-title !mt-0" data-reel="left">
{category.category}
</h3>
<DrawLine delay={0.05} />
<p className="ds-copy !mt-sp-4" data-reel="right">
Ferramentas que uso para construir com clareza tecnica e ritmo de
entrega.
</p>
<ul className="ds-tags" data-reel-stagger="fade">
{category.skills.map((skill) => (
<li key={skill} className="ds-tag">
{skill}
</li>
))}
</ul>
</motion.article>
))}
</motion.div>
</Section>
);
}
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import type { Testimonial } from "@/lib/proof";
import Testimonials from "./Testimonials";
/**
* Testes de caminhos-base de Testimonials e de initials() (complemento de Testimonials.test.tsx):
* foto presente, iniciais com 1, 2 e 3 palavras, espaços extras, e vários depoimentos.
*/
const base: Testimonial = {
quote: "Entregou no prazo combinado.",
name: "Maria Souza",
role: "Sócia",
company: "Empresa de Teste",
};
describe("Testimonials", () => {
it("path: with a photo -> shows the decorative image and no initials", () => {
const { container } = render(
<Testimonials items={[{ ...base, photo: "/clientes/maria.jpg" }]} />,
);
const image = container.querySelector("img");
expect(image).not.toBeNull();
expect(image).toHaveAttribute("alt", "");
expect(image?.getAttribute("src")).toContain("maria");
expect(screen.queryByText("MS")).toBeNull();
});
it.each([
["Maria", "M"],
["Maria Souza", "MS"],
["maria da silva souza", "MD"],
[" Maria Souza ", "MS"],
])("path: name %j without a photo -> initials %j", (name, initials) => {
const { container } = render(<Testimonials items={[{ ...base, name }]} />);
expect(container.querySelector("img")).toBeNull();
expect(
container.querySelector("span[aria-hidden='true']"),
).toHaveTextContent(new RegExp(`^${initials}$`));
});
it("path: several testimonials -> one card each, keyed by company and name", () => {
render(
<Testimonials
items={[
base,
{ ...base, name: "João Lima", company: "Outra Empresa" },
{ ...base, name: "Maria Souza", company: "Outra Empresa" },
]}
/>,
);
expect(screen.getAllByRole("listitem")).toHaveLength(3);
expect(screen.getAllByText(/Entregou no prazo/)).toHaveLength(3);
expect(screen.getAllByText("Sócia, Outra Empresa")).toHaveLength(2);
});
it("path: a testimonial with a project -> the case link points to that project", () => {
render(<Testimonials items={[{ ...base, projectId: "sc-plus" }]} />);
expect(screen.getByRole("link", { name: "Ver o case" })).toHaveAttribute(
"href",
"/cases/sc-plus",
);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import type { Testimonial } from "@/lib/proof";
import { testimonials } from "@/lib/proof";
import Testimonials from "./Testimonials";
const item: Testimonial = {
quote: "Entregou no prazo combinado.",
name: "Maria Souza",
role: "Sócia",
company: "Empresa de Teste",
projectId: "lavacao-047",
};
it("renders nothing without testimonials", () => {
const { container } = render(<Testimonials items={[]} />);
expect(container).toBeEmptyDOMElement();
const withoutProp = render(<Testimonials />);
expect(withoutProp.container).toBeEmptyDOMElement();
});
it("ships with no testimonial until a real, authorized one is added", () => {
expect(testimonials).toEqual([]);
});
it("shows the quote, who said it and a link to the case", () => {
render(<Testimonials items={[item]} />);
expect(
screen.getByRole("heading", { name: "O que dizem os clientes." }),
).toBeInTheDocument();
expect(
screen.getByText("“Entregou no prazo combinado.”"),
).toBeInTheDocument();
expect(screen.getByText("Maria Souza")).toBeInTheDocument();
expect(screen.getByText("Sócia, Empresa de Teste")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Ver o case" })).toHaveAttribute(
"href",
"/cases/lavacao-047",
);
});
it("falls back to initials without a photo and has no case link without a project", () => {
render(<Testimonials items={[{ ...item, projectId: undefined }]} />);
expect(screen.getByText("MS")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Ver o case" })).toBeNull();
});
+86
View File
@@ -0,0 +1,86 @@
import Image from "next/image";
import Link from "next/link";
import type { Testimonial } from "@/lib/proof";
import { textLink } from "./button-styles";
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase();
}
/**
* Depoimentos reais e autorizados (lib/proof.ts). Sem nenhum, a seção não existe:
* nada de depoimento de exemplo ou escrito em nome de alguém.
*/
export default function Testimonials({
items = [],
}: {
items?: Testimonial[];
}) {
if (items.length === 0) return null;
return (
<section
id="testimonials"
aria-labelledby="testimonials-title"
className="ds-section"
>
<div className="ds-section-shell">
<p className="ds-eyebrow">Depoimentos</p>
<h2 id="testimonials-title" className="ds-section-title max-w-[22ch]">
O que dizem os clientes.
</h2>
<ul className="mt-sp-8 grid gap-sp-6 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<li key={`${item.company}-${item.name}`} className="flex">
<figure className="ds-card flex w-full flex-col justify-between gap-sp-6">
<blockquote className="font-display text-lg leading-8 text-[var(--ink)]">
<p>{`“${item.quote}”`}</p>
</blockquote>
<figcaption className="flex items-center gap-sp-4">
{item.photo ? (
<Image
src={item.photo}
alt=""
width={48}
height={48}
className="h-12 w-12 shrink-0 rounded-full object-cover"
/>
) : (
<span
aria-hidden="true"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--ink)] font-display text-sm font-semibold text-[var(--paper)]"
>
{initials(item.name)}
</span>
)}
<div className="min-w-0 font-display text-[15px] leading-6">
<p className="font-semibold text-[var(--ink)]">
{item.name}
</p>
<p className="text-[var(--meta)]">
{item.role}, {item.company}
</p>
{item.projectId && (
<Link
href={`/cases/${item.projectId}`}
className={`${textLink} !min-h-0 text-[var(--ink-80)]`}
>
Ver o case
</Link>
)}
</div>
</figcaption>
</figure>
</li>
))}
</ul>
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { fireEvent, render, screen } from "@testing-library/react";
import WhatsAppLink from "./WhatsAppLink";
/**
* Testes de caminhos-base de WhatsAppLink (complemento de WhatsAppLink.test.tsx): o rótulo de métrica sem
* assunto ("Contato") e cada botão do mouse nos dois eventos (click e auxclick).
*/
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
window.gtag = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
function renderLink(props: { service?: string } = {}) {
render(
<WhatsAppLink placement="contact_direct" {...props}>
Conversar
</WhatsAppLink>,
);
return screen.getByRole("link", { name: "Conversar" });
}
const auxclick = (button: number) =>
new MouseEvent("auxclick", { button, bubbles: true, cancelable: true });
describe("WhatsAppLink", () => {
it("path: no service -> the message says 'um projeto' and the metric says 'Contato'", () => {
const link = renderLink();
fireEvent.click(link, { button: 0 });
expect(
new URL(link.getAttribute("href") ?? "").searchParams.get("text"),
).toContain("sobre um projeto.");
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({
service: "Contato",
placement: "contact_direct",
}),
);
});
it("path: with a service -> the same text goes to the message and to the metric", () => {
const link = renderLink({ service: "Sistemas web" });
fireEvent.click(link, { button: 0 });
expect(
new URL(link.getAttribute("href") ?? "").searchParams.get("text"),
).toContain("sobre Sistemas web.");
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: "Sistemas web" }),
);
});
it.each([1, 2])(
"path: click event with button %i -> not counted",
(button) => {
const link = renderLink();
fireEvent.click(link, { button });
expect(window.gtag).not.toHaveBeenCalled();
},
);
it("path: auxclick with the primary button (0) -> not counted", () => {
const link = renderLink();
fireEvent(link, auxclick(0));
expect(window.gtag).not.toHaveBeenCalled();
});
it("path: middle-button auxclick (1) -> counted once with the same metric labels", () => {
const link = renderLink();
fireEvent(link, auxclick(1));
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({
service: "Contato",
placement: "contact_direct",
}),
);
});
it("path: no className given -> only the focus ring classes are applied", () => {
const link = renderLink();
expect(link.className.trim().startsWith("focus-visible:outline-none")).toBe(
true,
);
});
});
+20
View File
@@ -34,6 +34,26 @@ function renderLink() {
return screen.getByRole("link", { name: "Conversar no WhatsApp" });
}
it("talks about the service when one is given", () => {
const url = new URL(renderLink().getAttribute("href") || "");
expect(url.searchParams.get("text")).toBe(
"Olá, Evandro! Quero conversar sobre Sistemas web. Contato iniciado pelo site evandro.dev.br.",
);
});
it("talks about 'um projeto' when no service is given", () => {
render(<WhatsAppLink placement="contact_direct">Conversar</WhatsAppLink>);
const url = new URL(
screen.getByRole("link", { name: "Conversar" }).getAttribute("href") || "",
);
const text = url.searchParams.get("text");
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.",
);
expect(text).not.toContain("Contato.");
});
it.each([null, "denied", "{malformed", '"granted"'])(
"keeps a native destination without collecting or replaying when consent is %s",
async (consent) => {
+11 -4
View File
@@ -7,29 +7,36 @@ import { buildWhatsAppUrl } from "@/lib/whatsapp";
interface WhatsAppLinkProps {
children: ReactNode;
className?: string;
service: string;
/** Assunto da conversa, na frase "Quero conversar sobre {service}". Sem ele, a mensagem fala de "um projeto". */
service?: string;
placement: string;
}
/** Rótulo de métrica quando o link não traz um assunto; o texto da mensagem usa "um projeto". */
const DEFAULT_ANALYTICS_SERVICE = "Contato";
export default function WhatsAppLink({
children,
className = "",
service,
placement,
}: WhatsAppLinkProps) {
const analyticsService = service ?? DEFAULT_ANALYTICS_SERVICE;
return (
<a
href={buildWhatsAppUrl(
`Olá, Evandro! Quero conversar sobre ${service}. Contato iniciado pelo site evandro.dev.br.`,
`Olá, Evandro! Quero conversar sobre ${service ?? "um projeto"}. Contato iniciado pelo site evandro.dev.br.`,
)}
target="_blank"
rel="noopener noreferrer"
className={`${className} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]`}
onClick={(event) => {
if (event.button === 0) trackWhatsAppOpen({ service, placement });
if (event.button === 0)
trackWhatsAppOpen({ service: analyticsService, placement });
}}
onAuxClick={(event) => {
if (event.button === 1) trackWhatsAppOpen({ service, placement });
if (event.button === 1)
trackWhatsAppOpen({ service: analyticsService, placement });
}}
>
{children}
+14
View File
@@ -0,0 +1,14 @@
// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons).
export function WhatsAppMark({ className }: { className: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
focusable="false"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
);
}
@@ -0,0 +1,203 @@
import { render, screen, within } from "@testing-library/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { primaryButton, secondaryButtonLight } from "./button-styles";
import { WorkModalities } from "./WorkModalities";
/**
* Testes de caminhos-base de WorkModalities e TierCard: movimento normal x reduzido, plano em destaque
* (botão e borda) x demais planos, e serviços do hub ausentes x presentes.
* O `motion/react` é trocado por elementos simples que registram as props de animação.
*/
const mockSeen = jest.fn();
jest.mock("motion/react", () => {
const React = require("react");
const cache = new Map<string, unknown>();
const motion = new Proxy(
{},
{
get: (_target, tag: string) => {
if (!cache.has(tag)) {
cache.set(
tag,
({
children,
variants,
whileHover,
whileTap,
transition,
initial: _initial,
animate: _animate,
whileInView: _whileInView,
viewport: _viewport,
...props
}: Record<string, unknown> & { children?: unknown }) => {
mockSeen(tag, { variants, whileHover, whileTap, transition });
return React.createElement(tag, props, children);
},
);
}
return cache.get(tag);
},
},
);
return { motion };
});
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: jest.fn(),
}));
jest.mock("./Section", () => ({
Section: ({ children, id }: { children?: React.ReactNode; id?: string }) => (
<section id={id}>{children}</section>
),
}));
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
type Seen = {
variants?: {
hidden: Record<string, unknown>;
visible: { transition: Record<string, unknown> };
};
whileHover?: unknown;
whileTap?: unknown;
};
const serviceLinks = [
{
href: "/servicos/criacao-de-sites",
label: "Sites profissionais",
description: "Presença digital para a empresa.",
},
{
href: "/servicos/tauri-rust",
label: "Aplicativos de desktop",
description: "Programas rápidos para o computador.",
},
];
function seen(tag: string): Seen[] {
return mockSeen.mock.calls
.filter(([name]) => name === tag)
.map(([, props]) => props as Seen);
}
beforeEach(() => {
mockSeen.mockClear();
});
describe("WorkModalities: movimento", () => {
it("path: normal motion -> tiers rise in, with hover lift and press feedback", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
render(<WorkModalities />);
const items = seen("div").filter(
(props) => props.variants?.hidden?.opacity === 0,
);
expect(items).toHaveLength(3);
for (const item of items) {
expect(item.variants?.hidden).toEqual({
opacity: 0,
y: 20,
clipPath: "inset(100% 0 0 0)",
});
expect(item.variants?.visible.transition).toEqual({
duration: 0.75,
ease: [0.16, 1, 0.3, 1],
});
}
const cards = seen("article");
expect(cards).toHaveLength(3);
for (const card of cards) {
expect(card.whileHover).toEqual({
y: -4,
boxShadow: "0 24px 50px rgba(0, 0, 0, 0.08)",
});
expect(card.whileTap).toEqual({ scale: 0.99 });
}
});
it("path: reduced motion -> tiers only fade, with no hover lift and no press feedback", () => {
jest.mocked(useReducedMotion).mockReturnValue(true);
render(<WorkModalities />);
const items = seen("div").filter(
(props) => props.variants?.hidden?.opacity === 0,
);
expect(items).toHaveLength(3);
for (const item of items) {
expect(item.variants?.hidden).toEqual({ opacity: 0 });
expect(item.variants?.visible.transition).toEqual({ duration: 0.25 });
}
for (const card of seen("article")) {
expect(card.whileHover).toBeUndefined();
expect(card.whileTap).toBeUndefined();
}
});
});
describe("WorkModalities: plano em destaque", () => {
it("path: the featured tier has the filled button and the thick border; the others have outline buttons", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
const { container } = render(<WorkModalities />);
const featured = screen.getByRole("link", {
name: "Conversar sobre meu sistema",
});
expect(featured.className).toContain(primaryButton);
expect(featured.closest("article")).toHaveStyle({ borderWidth: "2px" });
expect(featured.closest("article")).toHaveClass("border-[var(--slash)]");
for (const name of ["Avaliar meu projeto", "Planejar a próxima etapa"]) {
const other = screen.getByRole("link", { name });
expect(other.className).toContain(secondaryButtonLight);
expect(other.className).not.toContain(primaryButton);
expect(other.closest("article")).toHaveStyle({ borderWidth: "1px" });
expect(other.closest("article")).not.toHaveClass("border-[var(--slash)]");
}
expect(container.querySelectorAll("article")).toHaveLength(3);
});
});
describe("WorkModalities: serviços do hub", () => {
it("path: no serviceLinks prop -> no service cards, the 'all services' link stays", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
render(<WorkModalities />);
expect(
screen.getByRole("link", { name: "Conhecer todos os serviços" }),
).toHaveAttribute("href", "/servicos");
expect(
screen.queryByRole("link", { name: /Sites profissionais/ }),
).toBeNull();
});
it("path: serviceLinks given -> one card per service, in order, linking to its page", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
const { container } = render(
<WorkModalities serviceLinks={serviceLinks} />,
);
const grid = container.querySelector(
"[data-reel-stagger='left']",
) as HTMLElement;
const links = within(grid).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
serviceLinks.map(({ href }) => href),
);
expect(
within(links[0]).getByText("Sites profissionais"),
).toBeInTheDocument();
expect(
within(links[1]).getByText("Programas rápidos para o computador."),
).toBeInTheDocument();
});
});
+18 -22
View File
@@ -3,9 +3,9 @@
import { motion, type Variants } from "motion/react";
import Link from "next/link";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { serviceHubLinks } from "@/lib/site-content";
import type { ServiceLink } from "@/lib/site-navigation";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { primaryButton, secondaryButtonLight } from "./button-styles";
import { Section } from "./Section";
interface Tier {
@@ -127,9 +127,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<div className="flex min-w-0 items-start justify-between gap-sp-4">
<span className="ds-kicker max-md:text-[10px] max-md:leading-[1.6]">
{tier.badge}
</span>
<span className="ds-kicker">{tier.badge}</span>
</div>
<DrawLine delay={0.04} />
@@ -138,13 +136,13 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
{tier.title}
</h3>
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
<p className="mt-sp-2 text-[13px] leading-[1.7] text-[var(--meta)]">
{tier.tagline}
</p>
</div>
<p
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[13px]"
data-reel="right"
>
{tier.description}
@@ -170,21 +168,24 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
</div>
</div>
<MagneticButton
as="a"
<Link
href={tier.ctaHref}
className={`ds-link mt-sp-6 min-h-[50px] w-full justify-center px-sp-4 py-sp-3 text-center text-[11px] leading-none ${
tier.featured ? "ds-link-accent" : "ds-link-muted"
className={`mt-sp-6 w-full ${
tier.featured ? primaryButton : secondaryButtonLight
}`}
strength={0.25}
>
{tier.ctaLabel}
</MagneticButton>
</Link>
</motion.article>
);
}
export function WorkModalities() {
export function WorkModalities({
serviceLinks = [],
}: {
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
serviceLinks?: ServiceLink[];
}) {
const shouldReduceMotion = useReducedMotion();
const containerVariants: Variants = {
@@ -233,21 +234,16 @@ export function WorkModalities() {
usar.
</p>
</div>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-accent"
strength={0.2}
>
<Link href="/servicos" className={`shrink-0 ${secondaryButtonLight}`}>
Conhecer todos os serviços
</MagneticButton>
</Link>
</div>
<div
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"
data-reel-stagger="left"
>
{serviceHubLinks.map((service) => (
{serviceLinks.map((service) => (
<Link
key={service.href}
href={service.href}
+30
View File
@@ -0,0 +1,30 @@
/**
* Botões da home e das páginas de conversão. Verde-limão preenchido só para a ação de contato;
* as demais ações são contorno ou link de texto. Texto em sans, 15 a 16 px, sem caixa-alta.
*/
const base =
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline transition-colors motion-reduce:transition-none sm:text-[16px]";
export const primaryButton = `${base} bg-[var(--slash)] font-semibold text-[var(--ink)] hover:bg-[#d9ff40]`;
/** Contorno sobre fundo escuro. */
export const secondaryButtonDark = `${base} border border-white/25 bg-transparent font-medium text-[#f4f2ed] hover:border-white/55`;
/** Contorno sobre fundo claro. */
export const secondaryButtonLight = `${base} border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)] hover:border-[var(--ink)]`;
const textLinkBase =
"inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 transition-colors motion-reduce:transition-none sm:text-[16px]";
/** Link de texto sobre fundo claro. */
export const textLink = `${textLinkBase} hover:text-[var(--ink)]`;
/** Link de texto sobre fundo escuro. */
export const textLinkDark = `${textLinkBase} text-white/80 hover:text-white`;
/**
* Primeira seção de uma página interna, no celular: o padding de .ds-section (52 px) é menor que o cabeçalho fixo (60 px),
* e a trilha de navegação ficava por baixo dele. O "!" vence o CSS sem camada de globals.css.
*/
export const pageTop =
"max-[768px]:!pt-[calc(var(--site-header-height)+var(--sp-6))]";
@@ -0,0 +1,202 @@
import { render, screen } from "@testing-library/react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import CinematicHome from "./CinematicHome";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/**
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
* presente sem site, ausente), a repasse das props opcionais, a ordem das cenas e a ligação dos hooks.
* As cenas e os hooks são trocados por dublês que registram o que recebem.
*/
const mockHeroProps = jest.fn();
const mockProjectsProps = jest.fn();
const mockModalitiesProps = jest.fn();
const mockTestimonialsProps = jest.fn();
const mockFooterProps = jest.fn();
jest.mock("../Hero", () => ({
__esModule: true,
default: (props: unknown) => {
mockHeroProps(props);
return <section data-testid="scene-hero" />;
},
}));
jest.mock("../Projects", () => ({
__esModule: true,
default: (props: unknown) => {
mockProjectsProps(props);
return <section data-testid="scene-projects" />;
},
}));
jest.mock("../About", () => ({
__esModule: true,
default: () => <section data-testid="scene-about" />,
}));
jest.mock("../WorkModalities", () => ({
WorkModalities: (props: unknown) => {
mockModalitiesProps(props);
return <section data-testid="scene-modalities" />;
},
}));
jest.mock("../EngineeringWithAI", () => ({
__esModule: true,
default: () => <section data-testid="scene-ai" />,
}));
jest.mock("../Testimonials", () => ({
__esModule: true,
default: (props: unknown) => {
mockTestimonialsProps(props);
return <section data-testid="scene-testimonials" />;
},
}));
jest.mock("../Contact", () => ({
__esModule: true,
default: () => <section data-testid="scene-contact" />,
}));
jest.mock("../Footer", () => ({
__esModule: true,
default: (props: unknown) => {
mockFooterProps(props);
return <footer data-testid="footer" />;
},
}));
jest.mock("../FloatingWhatsApp", () => ({
__esModule: true,
default: () => <div data-testid="floating-whatsapp" />,
}));
jest.mock("./useCinematicScroll", () => ({ useCinematicScroll: jest.fn() }));
jest.mock("./useHomeFragmentNavigation", () => ({
useHomeFragmentNavigation: jest.fn(),
}));
const onLayoutReady = jest.fn();
function project(id: string, demoUrl: string | null): PortfolioProjectView {
return { id, demoUrl } as unknown as PortfolioProjectView;
}
const lastHeroProps = () =>
mockHeroProps.mock.calls[mockHeroProps.mock.calls.length - 1][0];
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
});
describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project is in the list with a site -> its domain goes to the hero", () => {
render(
<CinematicHome
projects={[
project("sev-modaintima", "https://sevmodaintima.com.br"),
project(
"lavacao-047",
"https://www.landing-lavacao.vercel.app/pagina/",
),
]}
/>,
);
expect(lastHeroProps().workUrl).toEqual({
text: "landing-lavacao.vercel.app/pagina",
secure: true,
});
});
it("path: the featured project is in the list but has no site -> no address", () => {
render(<CinematicHome projects={[project("lavacao-047", null)]} />);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project is not in the list -> no address", () => {
render(
<CinematicHome
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: empty project list -> no address and the page still renders", () => {
render(<CinematicHome projects={[]} />);
expect(lastHeroProps().workUrl).toBeNull();
expect(screen.getByTestId("scene-contact")).toBeInTheDocument();
});
});
describe("CinematicHome: props e estrutura", () => {
it("path: every optional prop given -> each scene receives its own data", () => {
const projects = [project("lavacao-047", "https://a.example.com")];
const clients = [{ id: "lavacao-047", name: "Lavação 047" }];
const testimonials = [
{ quote: "Ótimo.", name: "Ana", role: "Sócia", company: "Empresa" },
];
const serviceLinks = [
{ href: "/servicos/x", label: "X", description: "Serviço X" },
];
render(
<CinematicHome
projects={projects}
clients={clients}
testimonials={testimonials}
serviceLinks={serviceLinks}
heroStats={{ years: 5 }}
/>,
);
expect(lastHeroProps()).toMatchObject({ stats: { years: 5 }, clients });
expect(mockProjectsProps).toHaveBeenCalledWith({ projects });
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: testimonials });
expect(mockModalitiesProps).toHaveBeenCalledWith({ serviceLinks });
expect(mockFooterProps).toHaveBeenCalledWith({ serviceLinks });
});
it("path: optional props omitted -> the scenes receive undefined and decide their own defaults", () => {
render(<CinematicHome projects={[]} />);
expect(lastHeroProps()).toMatchObject({
stats: undefined,
clients: undefined,
});
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: undefined });
expect(mockModalitiesProps).toHaveBeenCalledWith({
serviceLinks: undefined,
});
expect(mockFooterProps).toHaveBeenCalledWith({ serviceLinks: undefined });
});
it("path: scenes sit in order inside <main>; the footer and the floating button sit outside it", () => {
render(<CinematicHome projects={[]} />);
const main = screen.getByRole("main");
expect(
Array.from(main.children).map((child) =>
child.getAttribute("data-testid"),
),
).toEqual([
"scene-hero",
"scene-projects",
"scene-about",
"scene-modalities",
"scene-ai",
"scene-testimonials",
"scene-contact",
]);
expect(main).not.toContainElement(screen.getByTestId("footer"));
expect(main).not.toContainElement(screen.getByTestId("floating-whatsapp"));
});
it("path: mount -> the scroll hook gets the <main> ref and the layout-ready callback runs once", () => {
render(<CinematicHome projects={[]} />);
const ref = jest.mocked(useCinematicScroll).mock.calls[0][0];
expect(ref.current).toBe(screen.getByRole("main"));
expect(onLayoutReady).toHaveBeenCalledTimes(1);
});
});
+29 -26
View File
@@ -1,36 +1,45 @@
"use client";
import { useEffect, useRef } from "react";
import type {
PortfolioExperience,
PortfolioProjectView,
PortfolioSkillCategory,
} from "@/lib/portfolio-data";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import type { Testimonial } from "@/lib/proof";
import type { ServiceLink } from "@/lib/site-navigation";
import About from "../About";
import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI";
import Experience from "../Experience";
import FloatingWhatsApp from "../FloatingWhatsApp";
import Footer from "../Footer";
import Hero, { type HeroStats } from "../Hero";
import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import Skills from "../Skills";
import { displayUrl } from "../showcase/BrowserFrame";
import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities";
import ExperienceDisclosure from "./ExperienceDisclosure";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/** Projeto cujo endereço aparece na barra do navegador de HeroWork. */
const HERO_WORK_PROJECT = "lavacao-047";
export default function CinematicHome({
projects,
experiences,
skillCategories,
clients,
testimonials,
serviceLinks,
heroStats,
}: {
heroStats?: HeroStats;
/** Só projetos de cliente (o filtro é de app/page.tsx). */
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
clients?: ClientLink[];
testimonials?: Testimonial[];
/** Serviços do hub; lib/site-content.ts só pode ser lido no servidor, então a página os entrega prontos. */
serviceLinks?: ServiceLink[];
}) {
const heroWorkUrl = displayUrl({
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
?.demoUrl,
});
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
@@ -41,30 +50,24 @@ export default function CinematicHome({
<div className="flex min-h-screen flex-col overflow-x-clip">
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
<main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} />
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<Projects projects={projects} />
<WorkModalities />
<About />
<WorkModalities serviceLinks={serviceLinks} />
<EngineeringWithAI />
<LighthouseMetrics />
<div className="bg-[var(--paper)]">
<About />
<ExperienceDisclosure>
<Skills categories={skillCategories} />
<Experience experiences={experiences} />
</ExperienceDisclosure>
</div>
<Testimonials items={testimonials} />
<Contact />
</main>
<div className="relative z-10">
<Footer />
<Footer serviceLinks={serviceLinks} />
</div>
<FloatingWhatsApp />
</div>
);
}
@@ -1,63 +0,0 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
const TARGETS = new Set(["#experience", "#skills"]);
/**
* Skills e Experiência ficavam numa gaveta sempre fechada e sem link no menu.
* Agora abre sozinha em telas largas (onde há espaço) e quando um link aponta para #experience ou #skills;
* no celular continua recolhida para a página não crescer.
*/
export default function ExperienceDisclosure({
children,
}: {
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
const reveal = (hash: string) => {
if (!TARGETS.has(hash)) return;
setOpen(true);
// espera o React abrir a gaveta para o alvo existir no layout
requestAnimationFrame(() =>
document
.getElementById(hash.slice(1))
?.scrollIntoView({ behavior: "instant", block: "start" }),
);
};
if (window.matchMedia("(min-width: 1024px)").matches) setOpen(true);
else if (TARGETS.has(window.location.hash)) setOpen(true);
const onClick = (event: MouseEvent) => {
const link =
event.target instanceof Element
? event.target.closest("a[href*='#']")
: null;
if (!(link instanceof HTMLAnchorElement)) return;
const url = new URL(link.href);
if (url.pathname === window.location.pathname) reveal(url.hash);
};
const onHashChange = () => reveal(window.location.hash);
document.addEventListener("click", onClick);
window.addEventListener("hashchange", onHashChange);
return () => {
document.removeEventListener("click", onClick);
window.removeEventListener("hashchange", onHashChange);
};
}, []);
return (
<details
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
className="ds-section !pt-0"
>
<summary className="ds-section-shell cursor-pointer py-sp-5 text-lg font-semibold">
Experiência e tecnologias por trás das entregas
</summary>
{children}
</details>
);
}
+29
View File
@@ -0,0 +1,29 @@
/*
* globals.css zera a fonte dos <button> com `button { font: inherit }` fora de qualquer @layer, e uma regra sem camada
* vence as utilidades do Tailwind (camada utilities): font-display, text-base e font-semibold não chegam a um <button>.
* Os botões da home e do aviso de cookies pegam fonte, tamanho e peso destas classes. Texto de botão em sans, 15 a 16 px.
*/
.cv-btn {
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
line-height: 1.5rem;
letter-spacing: normal;
text-transform: none;
}
@media (min-width: 640px) {
.cv-btn {
font-size: 16px;
}
}
/* botão com cara de link (ex.: "Preferências de cookies" no rodapé) */
.cv-btn-link {
font-family: var(--font-display);
font-size: 15px;
font-weight: 400;
line-height: 1.5rem;
letter-spacing: normal;
text-transform: none;
}
+17
View File
@@ -0,0 +1,17 @@
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt = "Começou um sistema com IA? Revisão e continuidade";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default function Image() {
return new ImageResponse(
<OgCard
eyebrow="IA no desenvolvimento"
title="Começou um sistema com IA? Dá para partir daí."
description="Revisão do que foi gerado, correção dos riscos e continuidade até a produção."
/>,
OG_SIZE,
);
}
+68
View File
@@ -0,0 +1,68 @@
import { render, screen, within } from "@testing-library/react";
import AiPage, { metadata } from "./page";
it("uses its own title without the site suffix being added twice", () => {
expect(metadata.title).toEqual({
absolute:
"Começou um sistema com IA? Revisão e continuidade | Evandro Fonseca Junior",
});
expect(metadata).toMatchObject({
description: expect.any(String),
alternates: { canonical: "https://evandro.dev.br/ia" },
});
});
it("keeps the full AI content, with the cited incidents summarized only here", () => {
render(<AiPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Começou um sistema com IA? Dá para partir daí.",
);
for (const heading of [
"Se a IA cria um sistema, por que contratar um desenvolvedor?",
"O que casos documentados ensinam",
"E a LGPD?",
]) {
expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument();
}
for (const incident of [/Lovable/, /Replit/, /Base44/]) {
expect(screen.getByRole("heading", { name: incident })).toBeInTheDocument();
}
expect(
screen.getByRole("link", { name: "Reportagem da Semafor (em inglês)" }),
).toHaveAttribute("rel", "noopener noreferrer");
});
it("offers WhatsApp conversations about reviewing an AI-built system", () => {
render(<AiPage />);
const links = screen
.getAllByRole("link")
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
expect(links.map((link) => link.textContent)).toEqual([
"Falar sobre meu sistema",
"Avaliar meu projeto",
]);
for (const link of links) {
const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
"text",
);
// a mensagem tem de formar uma frase: "Quero conversar sobre a revisão de um sistema feito com IA."
expect(text).toContain(
"Quero conversar sobre a revisão de um sistema feito com IA.",
);
}
});
it("has breadcrumb navigation back to the home", () => {
render(<AiPage />);
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
expect(
within(breadcrumb).getByRole("link", { name: "Início" }),
).toHaveAttribute("href", "/");
expect(within(breadcrumb).getByText("IA no desenvolvimento")).toHaveAttribute(
"aria-current",
"page",
);
});
+269
View File
@@ -0,0 +1,269 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
textLinkDark,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/ia`;
const pageTitle =
"Começou um sistema com IA? Revisão e continuidade | Evandro Fonseca Junior";
const pageDescription =
"Protótipo ou sistema criado com IA? Avalio o que pode ser aproveitado, corrijo permissões, dados e publicação e levo o projeto até a produção.";
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp. */
const WHATSAPP_SERVICE = "a revisão de um sistema feito com IA";
export const metadata: Metadata = {
title: { absolute: pageTitle },
description: pageDescription,
...buildPageSocialMetadata({
url: pageUrl,
title: pageTitle,
description: pageDescription,
type: "website",
}),
};
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
description:
"Login não basta. Permissões precisam ser verificadas no servidor e testadas entre usuários, empresas e perfis diferentes.",
},
{
title: "Como recuperar a operação?",
description:
"Ambientes separados, acesso restrito à produção e backups com restauração testada reduzem o impacto de uma alteração errada.",
},
{
title: "Quem valida antes de publicar?",
description:
"Código gerado também passa por revisão, testes e avaliação de dependências. O escopo inclui manutenção, custos e riscos de operação.",
},
];
/** Três episódios de 2025, resumidos. Cada um traz o que foi relatado, o que não foi provado e o cuidado técnico. */
const INCIDENTS = [
{
title: "Lovable: dados expostos em aplicações criadas na plataforma",
summary:
"Em maio de 2025, a Semafor noticiou uma análise que encontrou 170 de 1.645 aplicações com acesso indevido a informações, incluindo dados de usuários e chaves de API, ligado à configuração de acesso dos bancos conectados. O fornecedor contesta que a falha seja da plataforma e lembra que os clientes respondem por seus dados. O relato não comprova roubo em todas essas aplicações.",
care: "testar se uma pessoa consegue ler ou alterar dados de outra, em vez de tratar a tela de login como prova de segurança.",
links: [
{
href: "https://www.semafor.com/article/05/29/2025/the-hottest-new-vibe-coding-startup-lovable-is-a-sitting-duck-for-hackers",
label: "Reportagem da Semafor (em inglês)",
},
{
href: "https://nvd.nist.gov/vuln/detail/CVE-2025-48757",
label: "Registro e contestação no NVD",
},
],
},
{
title: "Replit: um agente apagou dados de uma aplicação",
summary:
"Em julho de 2025, o cofundador da SaaStr relatou que um agente apagou dados durante a construção de uma aplicação, e as alterações de desenvolvimento podiam afetar a produção. A Replit reconheceu o episódio, informou que o banco foi recuperado por rollback e anunciou a separação entre bancos de desenvolvimento e produção por padrão. Houve exclusão e recuperação, não roubo de informações.",
care: "limitar permissões de agentes, separar ambientes e testar a recuperação antes de depender dela.",
links: [
{
href: "https://replit.com/blog/doubling-down-on-our-commitment-to-secure-vibe-coding",
label: "Relato e medidas da Replit (29/07/2025)",
},
],
},
{
title: "Base44: aplicações privadas com falha de acesso",
summary:
"Em julho de 2025, pesquisadores da Wiz demonstraram uma falha que permitia acesso não autorizado a aplicações privadas do Base44. Ela estava na camada compartilhada de autenticação da plataforma, não em erro de um usuário iniciante. A Wix corrigiu em menos de 24 horas e informou não ter encontrado exploração maliciosa.",
care: "avaliar também o fornecedor e as permissões efetivas das integrações, além do código da aplicação.",
links: [
{
href: "https://www.wiz.io/blog/critical-vulnerability-base44",
label: "Pesquisa da Wiz e resposta da Wix (29/07/2025)",
},
],
},
];
export default function AiPage() {
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["IA no desenvolvimento", pageUrl],
] as const),
],
};
return (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "IA no desenvolvimento", href: "/ia" },
]}
/>
<p className="ds-eyebrow">IA no desenvolvimento</p>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Começou um sistema com IA? Dá para partir daí.
</h1>
<p className="ds-copy max-w-[62ch]">
A IA permite testar ideias e construir protótipos mesmo sem
experiência em programação. Quando o sistema passa a cuidar de
clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que
foi gerado e validar como ele se comporta fora da demonstração.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3">
<WhatsAppLink
className={primaryButton}
service={WHATSAPP_SERVICE}
placement="ia_hero"
>
Falar sobre meu sistema
</WhatsAppLink>
<Link href="/cases" className={textLinkDark}>
Ver projetos entregues
</Link>
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-contratar-title">
<div className="ds-section-shell">
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">
Se a IA cria um sistema, por que contratar um desenvolvedor?
</h2>
<div className="mt-sp-6 border-l-2 border-[var(--slash)] pl-sp-5">
<p className="max-w-[75ch] text-lg leading-8 text-[var(--ink)]">
Você contrata engenharia para transformar as regras do seu negócio
em um sistema que possa ser testado, operado e mantido. A IA pode
fazer parte desse trabalho. Ela não dispensa decisões sobre
arquitetura, segurança e responsabilidade pelos dados.
</p>
</div>
<div className="mt-sp-8 grid gap-sp-6 md:grid-cols-3">
{RESPONSIBILITIES.map((item) => (
<article
key={item.title}
className="border-t border-[var(--border)] pt-sp-5"
>
<h3 className="text-lg font-semibold leading-snug">
{item.title}
</h3>
<p className="ds-copy">{item.description}</p>
</article>
))}
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-casos-title">
<div className="ds-section-shell">
<h2 id="ia-casos-title" className="ds-section-title max-w-[26ch]">
O que casos documentados ensinam
</h2>
<p className="ds-copy max-w-[75ch]">
São episódios de 2025, não avaliações atuais das plataformas. Eles
mostram riscos de configuração, permissões e operação; não provam
que todo sistema feito com IA seja inseguro ou que um profissional
elimine qualquer risco.
</p>
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
{INCIDENTS.map((incident) => (
<article key={incident.title} className="ds-card">
<h3 className="ds-card-title !mt-0">{incident.title}</h3>
<p className="ds-copy">{incident.summary}</p>
<p className="ds-copy">
<strong>O cuidado técnico:</strong> {incident.care}
</p>
<ul className="mt-sp-4 flex flex-col gap-sp-1">
{incident.links.map((link) => (
<li key={link.href}>
<a
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4"
href={link.href}
target="_blank"
rel="noopener noreferrer"
>
{link.label}
</a>
</li>
))}
</ul>
</article>
))}
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-lgpd-title">
<div className="ds-section-shell grid gap-sp-6 lg:grid-cols-2">
<article className="ds-card">
<h2 id="ia-lgpd-title" className="ds-card-title !mt-0">
E a LGPD?
</h2>
<p className="ds-copy">
Usar IA não afasta as obrigações de proteção de dados. A ANPD
exige medidas técnicas e administrativas também de agentes de
pequeno porte e esclarece que as flexibilizações não os isentam
das demais obrigações da LGPD, como bases legais e direitos dos
titulares.
</p>
<p className="ds-copy">
O trabalho técnico inclui controles de acesso, coleta limitada ao
necessário e rotinas de retenção e exclusão. Conformidade depende
também dos processos da empresa e de orientação jurídica;
contratar desenvolvimento não equivale a uma certificação de
adequação.
</p>
<a
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4"
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
target="_blank"
rel="noopener noreferrer"
>
ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13
</a>
</article>
<article className="ds-card">
<h2 className="ds-card-title !mt-0">
Já começou com IA? Podemos partir daí.
</h2>
<p className="ds-copy">
Seu protótipo ajuda a explicar a ideia. Antes de propor uma
reconstrução, avalio o que pode ser aproveitado, quais riscos
precisam de correção e o que falta para atender usuários em
produção.
</p>
<p className="ds-copy">
Você não precisa saber programar para conduzir o negócio. Precisa
entender o que está contratando, acompanhar a entrega e ter
clareza sobre quem cuida de cada parte do sistema.
</p>
<div className="mt-sp-6">
<WhatsAppLink
className={primaryButton}
service={WHATSAPP_SERVICE}
placement="ia_closing"
>
Avaliar meu projeto
</WhatsAppLink>
</div>
</article>
</div>
</section>
</main>
);
}
+6 -1
View File
@@ -131,6 +131,10 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
<head>
{/* Set arrival policy before native restoration and client-side layout changes. */}
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style>
{/* Sem JavaScript as animações de entrada (opacity:0 inline vindo do servidor e palavras de título em CSS) não rodam: tudo precisa aparecer. */}
<noscript>
<style>{`[style*="opacity:0"],[style*="clip-path"]{opacity:1!important;transform:none!important;clip-path:none!important}.text-reveal-word{opacity:1!important;transform:none!important}`}</style>
</noscript>
<script>{`
addEventListener("load", () => {
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
@@ -155,12 +159,13 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
<a className="skip-link" href="#main-content">
Pular para o conteúdo
</a>
{/* Aviso de cookies logo depois do "pular": fica cedo na ordem do Tab, ainda que fixo no rodapé da tela. */}
<GoogleAnalytics />
<Header />
<CinematicOverlays />
<div id="main-content" tabIndex={-1}>
{children}
</div>
<GoogleAnalytics />
</body>
</html>
);
+10 -15
View File
@@ -1,10 +1,9 @@
import { loadGithubRepoCounts } from "@/lib/github-stats";
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import { portfolio } from "@/lib/portfolio-data";
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
import { clientNames, isClientProject, testimonials } from "@/lib/proof";
import { serviceHubLinks } from "@/lib/site-content";
import CinematicHome from "./components/cinematic/CinematicHome";
/** A contagem do GitHub é consultada no servidor e renovada uma vez por dia. */
export const revalidate = 86_400;
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
function yearsOfExperience() {
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
@@ -12,19 +11,15 @@ function yearsOfExperience() {
}
export default async function Home() {
const [projects, repos] = await Promise.all([
loadFeaturedProjects(),
loadGithubRepoCounts(),
]);
// A home mostra só trabalho de cliente; protótipos e projetos pessoais ficam em /cases.
const projects = (await loadFeaturedProjects()).filter(isClientProject);
return (
<CinematicHome
heroStats={{
years: yearsOfExperience(),
repos: repos ?? undefined,
}}
heroStats={{ years: yearsOfExperience() }}
clients={clientNames}
projects={projects}
experiences={portfolio.experiences}
skillCategories={portfolio.skills.technical}
testimonials={testimonials}
serviceLinks={serviceHubLinks}
/>
);
}
+189
View File
@@ -0,0 +1,189 @@
import {
GA_DISABLE_KEY,
readConsent,
trackEmailClick,
trackSocialClick,
trackWhatsAppOpen,
} from "./analytics";
/**
* Testes de caminhos-base de lib/analytics.ts (complemento de analytics.test.ts):
* modelo de rota de cada página (analyticsPagePath), cada condição que silencia o envio (sendEvent)
* e a conversão do Google Ads (sendAdsConversion) com e sem etiqueta, com e sem consentimento.
*/
const CONSENT_KEY = "evandro.analytics-consent.v1";
const ADS_LABEL = "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL";
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
delete window[GA_DISABLE_KEY];
window.gtag = jest.fn();
window.history.replaceState({}, "", "/");
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
delete process.env[ADS_LABEL];
window.history.replaceState({}, "", "/");
});
function gtagCalls() {
return (window.gtag as jest.Mock).mock.calls;
}
describe("analyticsPagePath (via the page_path sent with each event)", () => {
it.each([
["/", "/"],
["/servicos", "/servicos"],
["/cases", "/cases"],
["/curriculo", "/curriculo"],
["/privacidade", "/privacidade"],
["/ia", "/ia"],
["/cases/", "/cases"],
["/ia/?utm=x#y", "/ia"],
["/servicos/criacao-de-sites", "/servicos/[slug]"],
["/servicos/qualquer/coisa/mais", "/servicos/[slug]"],
["/cases/sev-modaintima", "/cases/[slug]"],
["/cases/ana%40example.com?x=1", "/cases/[slug]"],
["/servicos-extra", "/other"],
["/cases-extra/abc", "/other"],
["/nao-existe", "/other"],
])("path: location %s -> page_path %s", (location, expected) => {
window.history.replaceState({}, "", location);
trackSocialClick({ network: "linkedin" });
expect(gtagCalls()).toHaveLength(1);
expect(gtagCalls()[0][2]).toMatchObject({
page_path: expected,
page_location: `https://evandro.dev.br${expected}`,
page_referrer: "",
});
});
});
describe("sendEvent: cada condição que silencia o envio", () => {
it("path: gtag exists but is not a function -> nothing sent, no conversion, no error", () => {
process.env[ADS_LABEL] = "abc123";
window.gtag = "not-a-function" as never;
expect(() =>
trackWhatsAppOpen({ service: "S", placement: "p" }),
).not.toThrow();
expect(() => trackEmailClick({ placement: "p" })).not.toThrow();
});
it("path: stored consent is an unknown value -> treated as no consent", () => {
localStorage.setItem(CONSENT_KEY, "yes");
trackSocialClick({ network: "linkedin" });
expect(readConsent()).toBeNull();
expect(gtagCalls()).toHaveLength(0);
});
it("path: storage throws -> readConsent returns null and nothing is sent", () => {
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage blocked");
});
expect(readConsent()).toBeNull();
trackSocialClick({ network: "linkedin" });
expect(gtagCalls()).toHaveLength(0);
});
it("path: event counted -> one gtag call carrying the static params and the route template", () => {
window.history.replaceState({}, "", "/cases/sev-modaintima?q=1#x");
trackEmailClick({ placement: "footer" });
expect(gtagCalls()).toEqual([
[
"event",
"email_click",
{
send_to: "G-QTT9QT0T3S",
placement: "footer",
page_path: "/cases/[slug]",
page_location: "https://evandro.dev.br/cases/[slug]",
page_referrer: "",
},
],
]);
});
});
describe("sendAdsConversion: etiqueta x consentimento", () => {
it.each([
["is unset", undefined],
["is an empty string", ""],
])(
"path: label %s, consent granted -> only the GA4 event goes out",
(_l, label) => {
if (label !== undefined) process.env[ADS_LABEL] = label;
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
trackEmailClick({ placement: "footer" });
expect(gtagCalls().map(([, name]) => name)).toEqual([
"whatsapp_open",
"email_click",
]);
},
);
it("path: label set, consent denied -> nothing at all, not even the conversion", () => {
process.env[ADS_LABEL] = "abc123";
localStorage.setItem(CONSENT_KEY, "denied");
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
trackEmailClick({ placement: "footer" });
expect(gtagCalls()).toHaveLength(0);
});
it("path: label set, consent granted -> the conversion uses the Ads id and the label", () => {
process.env[ADS_LABEL] = "xyz-789";
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
expect(gtagCalls().map(([, name]) => name)).toEqual([
"whatsapp_open",
"conversion",
]);
expect(gtagCalls()[1][2]).toEqual({
send_to: "AW-18432694647/xyz-789",
});
});
it("path: the conversion call throws -> the click handler still returns normally", () => {
process.env[ADS_LABEL] = "abc123";
window.gtag = jest.fn((_command: unknown, name: unknown) => {
if (name === "conversion") throw new Error("ads blocked");
});
expect(() =>
trackWhatsAppOpen({ service: "Contato", placement: "floating" }),
).not.toThrow();
expect(gtagCalls().map(([, name]) => name)).toEqual([
"whatsapp_open",
"conversion",
]);
});
it("path: gtag disappears right after the event was counted -> the conversion is skipped silently", () => {
process.env[ADS_LABEL] = "abc123";
const gtag = jest.fn(() => {
delete window.gtag;
});
window.gtag = gtag;
expect(() => trackEmailClick({ placement: "footer" })).not.toThrow();
expect(gtag).toHaveBeenCalledTimes(1);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
/**
* Caminhos de lib/analytics.ts quando não existe `window` (renderização no servidor): nenhum rastreio,
* nenhuma conversão e nenhum erro. O setup do projeto exige o jsdom e o `window` dele não pode ser
* escondido, então o próprio fonte é transpilado e executado com `window` e `localStorage` indefinidos.
*/
type AnalyticsModule = typeof import("./analytics");
const ADS_LABEL = "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL";
function loadWithoutWindow(storage?: Storage): AnalyticsModule {
const source = readFileSync(join(process.cwd(), "lib/analytics.ts"), "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
});
const loaded = { exports: {} as AnalyticsModule };
new Function("module", "exports", "window", "localStorage", outputText)(
loaded,
loaded.exports,
undefined,
storage,
);
return loaded.exports;
}
let gtag: jest.Mock;
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
process.env[ADS_LABEL] = "abc123";
localStorage.setItem("evandro.analytics-consent.v1", "granted");
gtag = jest.fn();
window.gtag = gtag;
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
delete process.env[ADS_LABEL];
});
describe("analytics without a window", () => {
it("path: every tracker is silent and never throws, even with granted consent and a conversion label", () => {
// o consentimento está "granted" no storage real: só a ausência de `window` impede o envio
const analytics = loadWithoutWindow(localStorage);
expect(() => {
analytics.trackWhatsAppOpen({
service: "Contato",
placement: "floating",
});
analytics.trackEmailClick({ placement: "footer" });
analytics.trackSocialClick({ network: "linkedin" });
analytics.trackCvDownload({ placement: "curriculo" });
}).not.toThrow();
expect(gtag).not.toHaveBeenCalled();
});
it("path: readConsent finds no storage -> null", () => {
expect(loadWithoutWindow().readConsent()).toBeNull();
});
it("path: openCookiePreferences has no window to notify -> returns without error", () => {
const listener = jest.fn();
window.addEventListener("evandro:open-cookie-preferences", listener);
expect(() => loadWithoutWindow().openCookiePreferences()).not.toThrow();
window.removeEventListener("evandro:open-cookie-preferences", listener);
expect(listener).not.toHaveBeenCalled();
});
it("control: the same source with a window does dispatch the reopen event", () => {
const listener = jest.fn();
window.addEventListener("evandro:open-cookie-preferences", listener);
const source = readFileSync(
join(process.cwd(), "lib/analytics.ts"),
"utf8",
);
const { outputText } = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
});
const loaded = { exports: {} as AnalyticsModule };
new Function("module", "exports", "window", outputText)(
loaded,
loaded.exports,
window,
);
loaded.exports.openCookiePreferences();
window.removeEventListener("evandro:open-cookie-preferences", listener);
expect(listener).toHaveBeenCalledTimes(1);
});
});
+166
View File
@@ -0,0 +1,166 @@
import {
GA_DISABLE_KEY,
OPEN_COOKIE_PREFERENCES_EVENT,
openCookiePreferences,
trackCvDownload,
trackEmailClick,
trackSocialClick,
trackWhatsAppOpen,
} from "./analytics";
const CONSENT_KEY = "evandro.analytics-consent.v1";
const ADS_LABEL = "NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL";
const common = {
send_to: "G-QTT9QT0T3S",
page_path: "/",
page_location: "https://evandro.dev.br/",
page_referrer: "",
};
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem(CONSENT_KEY, "granted");
delete window[GA_DISABLE_KEY];
window.gtag = jest.fn();
window.history.replaceState({}, "", "/");
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
delete process.env[ADS_LABEL];
});
describe("events", () => {
it("sends email_click, social_click and cv_download with static labels only", () => {
trackEmailClick({ placement: "footer" });
trackSocialClick({ network: "linkedin" });
trackCvDownload({ placement: "curriculo" });
expect(window.gtag).toHaveBeenNthCalledWith(1, "event", "email_click", {
...common,
placement: "footer",
});
expect(window.gtag).toHaveBeenNthCalledWith(2, "event", "social_click", {
...common,
network: "linkedin",
});
expect(window.gtag).toHaveBeenNthCalledWith(3, "event", "cv_download", {
...common,
placement: "curriculo",
});
expect(window.gtag).toHaveBeenCalledTimes(3);
});
it("reports the floating button as a whatsapp_open placement", () => {
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
...common,
service: "Contato",
placement: "floating",
});
});
it("knows the /ia page instead of lumping it into /other", () => {
window.history.replaceState({}, "", "/ia?x=1#y");
trackEmailClick({ placement: "ia" });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({
page_path: "/ia",
page_location: "https://evandro.dev.br/ia",
}),
);
});
it.each([
["without consent", () => localStorage.removeItem(CONSENT_KEY)],
["after refusal", () => localStorage.setItem(CONSENT_KEY, "denied")],
[
"when GA is disabled",
() => Object.assign(window, { [GA_DISABLE_KEY]: true }),
],
["when gtag is missing", () => delete window.gtag],
])("sends nothing %s", (_label, arrange) => {
process.env[ADS_LABEL] = "abc123";
const gtag = window.gtag as jest.Mock;
arrange();
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
trackEmailClick({ placement: "footer" });
trackSocialClick({ network: "linkedin" });
trackCvDownload({ placement: "curriculo" });
expect(gtag).not.toHaveBeenCalled();
});
it("sends nothing outside production", () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
trackEmailClick({ placement: "footer" });
expect(window.gtag).not.toHaveBeenCalled();
});
it("never throws when gtag fails", () => {
window.gtag = jest.fn(() => {
throw new Error("blocked");
});
expect(() => trackEmailClick({ placement: "footer" })).not.toThrow();
});
});
describe("Google Ads conversion", () => {
it("is not sent without the conversion label", () => {
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
trackEmailClick({ placement: "footer" });
expect(window.gtag).toHaveBeenCalledTimes(2);
for (const [, name] of (window.gtag as jest.Mock).mock.calls) {
expect(name).not.toBe("conversion");
}
});
it("goes out next to whatsapp_open and email_click when the label exists", () => {
process.env[ADS_LABEL] = "abc123";
trackWhatsAppOpen({ service: "Contato", placement: "floating" });
trackEmailClick({ placement: "footer" });
const calls = (window.gtag as jest.Mock).mock.calls;
expect(calls.map(([, name]) => name)).toEqual([
"whatsapp_open",
"conversion",
"email_click",
"conversion",
]);
expect(calls[1]).toEqual([
"event",
"conversion",
{ send_to: "AW-18432694647/abc123" },
]);
});
it("does not turn social clicks or CV downloads into Ads conversions", () => {
process.env[ADS_LABEL] = "abc123";
trackSocialClick({ network: "linkedin" });
trackCvDownload({ placement: "curriculo" });
expect(window.gtag).toHaveBeenCalledTimes(2);
});
});
describe("cookie preferences", () => {
it("asks the consent bar to reopen through a window event", () => {
const listener = jest.fn();
window.addEventListener(OPEN_COOKIE_PREFERENCES_EVENT, listener);
openCookiePreferences();
window.removeEventListener(OPEN_COOKIE_PREFERENCES_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
});
});
+63 -12
View File
@@ -1,4 +1,5 @@
export const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
export const GOOGLE_ADS_ID = "AW-18432694647";
export const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
export const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
@@ -30,6 +31,7 @@ function analyticsPagePath(): string {
case "/cases":
case "/curriculo":
case "/privacidade":
case "/ia":
return path;
default:
// Route templates keep arbitrary slugs and personal data out of metrics.
@@ -39,16 +41,14 @@ function analyticsPagePath(): string {
}
}
/** Measures an attempted handoff, not an opened app, delivered message or lead.
type EventParams = Record<string, string>;
/**
* Sends one GA4 event only while the visitor has granted consent; every other state is silent.
* Callers must supply static labels, never form values or destination URLs.
* Returns true when the event was handed to gtag, so the Ads conversion is only sent for a counted click.
*/
export function trackWhatsAppOpen({
service,
placement,
}: {
service: string;
placement: string;
}): void {
function sendEvent(name: string, params: EventParams): boolean {
if (
process.env.NODE_ENV !== "production" ||
typeof window === "undefined" ||
@@ -56,21 +56,72 @@ export function trackWhatsAppOpen({
window[GA_DISABLE_KEY] === true ||
typeof window.gtag !== "function"
) {
return;
return false;
}
try {
const pagePath = analyticsPagePath();
window.gtag("event", "whatsapp_open", {
window.gtag("event", name, {
send_to: GA_MEASUREMENT_ID,
service,
placement,
...params,
page_path: pagePath,
// Override gtag's URL defaults so query/hash/referrer data cannot leak.
page_location: `https://evandro.dev.br${pagePath}`,
page_referrer: "",
});
return true;
} catch {
// Analytics must never interrupt navigation. Dropped events are not replayed.
return false;
}
}
/**
* Google Ads conversion for the same click, only when a conversion label is configured.
* Without NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL nothing is sent to Ads.
*/
function sendAdsConversion(): void {
const label = process.env.NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL;
if (!label) return;
try {
window.gtag?.("event", "conversion", {
send_to: `${GOOGLE_ADS_ID}/${label}`,
});
} catch {
// Same rule as above: a blocked tag never interrupts navigation.
}
}
/** Measures an attempted handoff, not an opened app, delivered message or lead. */
export function trackWhatsAppOpen({
service,
placement,
}: {
service: string;
placement: string;
}): void {
if (sendEvent("whatsapp_open", { service, placement })) sendAdsConversion();
}
/** Measures a click on a mailto: link, not a sent e-mail. */
export function trackEmailClick({ placement }: { placement: string }): void {
if (sendEvent("email_click", { placement })) sendAdsConversion();
}
/** Measures a click on a social profile; `network` is a static name such as "linkedin". */
export function trackSocialClick({ network }: { network: string }): void {
sendEvent("social_click", { network });
}
/** Measures a click on the résumé PDF download. */
export function trackCvDownload({ placement }: { placement: string }): void {
sendEvent("cv_download", { placement });
}
/** O rodapé pede a reabertura da barra de cookies; quem a monta (GoogleAnalytics) escuta este evento. */
export const OPEN_COOKIE_PREFERENCES_EVENT = "evandro:open-cookie-preferences";
export function openCookiePreferences(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(OPEN_COOKIE_PREFERENCES_EVENT));
}
-126
View File
@@ -1,126 +0,0 @@
import {
countOwnRepos,
GITHUB_STATS_REVALIDATE_SECONDS,
loadGithubRepoCounts,
loadGithubStatsSnapshot,
} from "./github-stats";
const repo = (
overrides: Partial<{ private: boolean; fork: boolean; archived: boolean }>,
) => ({ private: false, fork: false, archived: false, ...overrides });
function jsonResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}
const noSnapshot = async () => null;
const snapshot = { total: 68, public: 25, private: 43 };
describe("countOwnRepos", () => {
it("counts public and private repos and ignores forks and archived ones", () => {
const counts = countOwnRepos([
repo({}),
repo({}),
repo({ private: true }),
repo({ fork: true }),
repo({ private: true, fork: true }),
repo({ archived: true }),
repo({ private: true, archived: true }),
]);
expect(counts).toEqual({ total: 3, public: 2, private: 1 });
});
});
describe("loadGithubRepoCounts", () => {
it("returns null without a token and without a snapshot, and never calls the API", async () => {
const fetchImpl = jest.fn();
await expect(
loadGithubRepoCounts(undefined, fetchImpl, noSnapshot),
).resolves.toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it("falls back to the last known snapshot without a token", async () => {
const fetchImpl = jest.fn();
await expect(
loadGithubRepoCounts(undefined, fetchImpl, async () => snapshot),
).resolves.toEqual(snapshot);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("prefers the live count over the snapshot", async () => {
const fetchImpl = jest
.fn()
.mockResolvedValue(jsonResponse([repo({}), repo({ private: true })]));
await expect(
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
).resolves.toEqual({ total: 2, public: 1, private: 1 });
});
it("sends the token only to the GitHub API and revalidates daily", async () => {
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse([repo({})]));
await loadGithubRepoCounts("secret-token", fetchImpl, noSnapshot);
const [url, init] = fetchImpl.mock.calls[0];
expect(new URL(url).origin).toBe("https://api.github.com");
expect(url).toContain("affiliation=owner");
expect(url).toContain("visibility=all");
expect(init.headers.authorization).toBe("Bearer secret-token");
expect(init.next.revalidate).toBe(GITHUB_STATS_REVALIDATE_SECONDS);
expect(GITHUB_STATS_REVALIDATE_SECONDS).toBe(86_400);
});
it("walks every page before counting", async () => {
const firstPage = Array.from({ length: 100 }, () => repo({}));
const secondPage = [repo({ private: true }), repo({ fork: true })];
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(firstPage))
.mockResolvedValueOnce(jsonResponse(secondPage));
await expect(
loadGithubRepoCounts("token", fetchImpl, noSnapshot),
).resolves.toEqual({
total: 101,
public: 100,
private: 1,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("uses the snapshot when the API rejects the token", async () => {
const fetchImpl = jest
.fn()
.mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401));
await expect(
loadGithubRepoCounts("bad", fetchImpl, async () => snapshot),
).resolves.toEqual(snapshot);
});
it("uses the snapshot on network failure instead of breaking the page", async () => {
const fetchImpl = jest.fn().mockRejectedValue(new Error("offline"));
await expect(
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
).resolves.toEqual(snapshot);
});
});
describe("loadGithubStatsSnapshot", () => {
it("reads a coherent snapshot from data/github-stats.json", async () => {
const counts = await loadGithubStatsSnapshot();
expect(counts).not.toBeNull();
expect(counts?.total).toBe((counts?.public ?? 0) + (counts?.private ?? 0));
});
});
-109
View File
@@ -1,109 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
/**
* Contagem de repositórios próprios no GitHub (públicos + privados), consultada só no servidor.
* O token (GITHUB_STATS_TOKEN, fine-grained, somente leitura de metadados) nunca recebe prefixo NEXT_PUBLIC_
* e este módulo só é importado por componentes de servidor: ele não chega ao navegador.
*
* Sem token, ou se a API falhar, vale o último retrato conhecido em data/github-stats.json.
*/
export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60;
const REPOS_ENDPOINT = "https://api.github.com/user/repos";
const PAGE_SIZE = 100;
const MAX_PAGES = 20;
const REQUEST_TIMEOUT_MS = 10_000;
export interface GithubRepoCounts {
total: number;
public: number;
private: number;
}
interface RepoSummary {
private: boolean;
fork: boolean;
archived: boolean;
}
type CachedRequestInit = RequestInit & { next: { revalidate: number } };
type FetchLike = (url: string, init: CachedRequestInit) => Promise<Response>;
/** Só repositórios meus: forks e arquivados ficam de fora. */
export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts {
let publicCount = 0;
let privateCount = 0;
for (const repo of repos) {
if (repo.fork || repo.archived) continue;
if (repo.private) privateCount += 1;
else publicCount += 1;
}
return {
total: publicCount + privateCount,
public: publicCount,
private: privateCount,
};
}
async function fetchGithubRepoCounts(
token: string | undefined,
fetchImpl: FetchLike,
): Promise<GithubRepoCounts | null> {
if (!token) return null;
const repos: RepoSummary[] = [];
try {
for (let page = 1; page <= MAX_PAGES; page += 1) {
const url = `${REPOS_ENDPOINT}?affiliation=owner&visibility=all&per_page=${PAGE_SIZE}&page=${page}`;
const response = await fetchImpl(url, {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "evandro-dev-portfolio",
"x-github-api-version": "2022-11-28",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
next: { revalidate: GITHUB_STATS_REVALIDATE_SECONDS },
});
if (!response.ok) return null;
const batch = (await response.json()) as RepoSummary[];
if (!Array.isArray(batch)) return null;
repos.push(
...batch.map(({ private: isPrivate, fork, archived }) => ({
private: isPrivate,
fork,
archived,
})),
);
if (batch.length < PAGE_SIZE) return countOwnRepos(repos);
}
return null;
} catch {
return null;
}
}
/** Último retrato conhecido (data/github-stats.json); `null` se o arquivo faltar ou estiver incoerente. */
export async function loadGithubStatsSnapshot(): Promise<GithubRepoCounts | null> {
try {
const path = resolve(process.cwd(), "data/github-stats.json");
const raw = JSON.parse(await readFile(path, "utf8")) as GithubRepoCounts;
const counts = [raw.total, raw.public, raw.private];
if (!counts.every((n) => Number.isInteger(n) && n >= 0)) return null;
if (raw.total !== raw.public + raw.private) return null;
return { total: raw.total, public: raw.public, private: raw.private };
} catch {
return null;
}
}
export async function loadGithubRepoCounts(
token: string | undefined = process.env.GITHUB_STATS_TOKEN,
fetchImpl: FetchLike = fetch,
readSnapshot: () => Promise<GithubRepoCounts | null> = loadGithubStatsSnapshot,
): Promise<GithubRepoCounts | null> {
return (await fetchGithubRepoCounts(token, fetchImpl)) ?? readSnapshot();
}
+30
View File
@@ -0,0 +1,30 @@
import { portfolio } from "./portfolio-data";
/** Projetos feitos para clientes aparecem como prova; os pessoais e protótipos ficam no "Laboratório". */
export type ProjectKind = "cliente" | "laboratorio";
export interface Testimonial {
quote: string;
name: string;
role: string;
company: string;
/** id do projeto em public/portfolio-data.json, para ligar o depoimento ao case. */
projectId?: string;
/** Caminho em /public; sem foto, mostra as iniciais. */
photo?: string;
}
/**
* Depoimentos reais, enviados e autorizados pelo próprio cliente. Nunca escrever um depoimento em nome de alguém.
* Lista vazia: a seção de depoimentos não aparece.
*/
export const testimonials: Testimonial[] = [];
export function isClientProject(project: { kind?: string }) {
return project.kind === "cliente";
}
/** Clientes na ordem dos projetos, para a faixa "Já entregue para". */
export const clientNames = portfolio.projects
.filter(isClientProject)
.map((project) => ({ id: project.id, name: project.title }));
+14 -3
View File
@@ -2,9 +2,20 @@ export const CV_URL = "/Profile.pdf";
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
export const NAVIGATION_ITEMS = [
{ href: "/#modalities", label: "Soluções" },
{ href: "/cases", label: "Projetos" },
{ href: "/#projects", label: "Projetos" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/#experience", label: "Experiência" },
{ href: "/cases", label: "Cases" },
{ href: "/servicos", label: "Serviços" },
] as const;
export const CONTACT_EMAIL = "[email protected]";
export const LINKEDIN_URL = "https://www.linkedin.com/in/evandrodevbr/";
export const INSTAGRAM_URL = "https://www.instagram.com/evandrodevbr/";
/** Serviço do hub, como a home o recebe: app/page.tsx lê lib/site-content.ts (que não pode ir para componentes de cliente). */
export interface ServiceLink {
href: string;
label: string;
description: string;
}