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:
1 parent
6e1022b58c
commit
8d07b8c6bb
60 files changed
+4392
-2124
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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$/);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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">
|
||||
<h1>
|
||||
</text>
|
||||
<path d="M331 317h213v29H331z" />
|
||||
<text x="350" y="337">
|
||||
<article> / <nav>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user