feat(home): posicionamento com prova, menu sem recarregar e WhatsApp em todo o site

H1 'Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.'; HeroWork maior com a SEV; menu Cases, Serviços, Como trabalho, Sobre e IA com next/link e pré-carregamento no hover; menu do celular em tela cheia; WhatsApp flutuante e no rodapé com a mensagem da rota; rodapé em todas as páginas; FAQ da home; /ia como oferta de revisão de app feito com IA; formulário com noscript, validação do mailto e plano B do pop-up; cartões sem foco indevido; sem a fonte Monument.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:25 -03:00
1 parent e80f73d075
commit 10f170abf4
56 files changed
+2982 -789

No files matched your search

+73 -100
View File
@@ -1,53 +1,12 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import About from "./About"; import About from "./About";
/** /**
* Testes de caminhos-base de About: todas as decisões dependem de `shouldReduceMotion` * Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o
* (variantes dos cards, transição, hover e toque), então há dois caminhos: movimento normal e reduzido. * feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de
* O `motion/react` é trocado por elementos simples que registram as props de animação. * apoio. Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS.
*/ */
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", () => ({ jest.mock("@/public/edev.jpg", () => ({
__esModule: true, __esModule: true,
default: { src: "/edev.jpg", width: 10, height: 10 }, default: { src: "/edev.jpg", width: 10, height: 10 },
@@ -58,13 +17,16 @@ jest.mock("./Section", () => ({
children, children,
id, id,
title, title,
aside,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
id?: string; id?: string;
title?: string; title?: string;
aside?: React.ReactNode;
}) => ( }) => (
<section id={id}> <section id={id}>
<h2>{title}</h2> <h2>{title}</h2>
{aside}
{children} {children}
</section> </section>
), ),
@@ -77,82 +39,93 @@ jest.mock("./animations/LazyRevealImage", () => ({
), ),
})); }));
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", () => { describe("About", () => {
it("path: normal motion -> cards rise and clip in, with hover lift and press feedback", () => { it("path: the four cards enter through Reveal -> same rise and duration, delays growing in steps", () => {
jest.mocked(useReducedMotion).mockReturnValue(false); const { container } = render(<About />);
render(<About />); const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
// 3 etapas da história + o card do perfil (o cabeçalho da seção é um dublê, sem Reveal)
const cards = renderedCards(); expect(reveals).toHaveLength(4);
expect(cards).toHaveLength(4); // 3 etapas da história + o card do perfil expect(
for (const card of cards) { reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
expect(card.variants?.hidden).toEqual({ ).toEqual(["20px", "20px", "20px", "20px"]);
opacity: 0, expect(
y: 20, reveals.map((element) =>
clipPath: "inset(100% 0 0 0)", element.style.getPropertyValue("--reveal-duration"),
}); ),
expect(card.variants?.visible.transition).toEqual({ ).toEqual(["0.8s", "0.8s", "0.8s", "0.8s"]);
duration: 0.8, const delays = reveals.map((element) =>
ease: [0.16, 1, 0.3, 1], Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
}); );
expect(card.whileHover).toEqual({ expect(delays).toEqual([0.08, 0.16, 0.24, 0.32]);
y: -4, for (const element of reveals) {
boxShadow: "0 18px 40px rgba(10,10,10,0.16)", expect(element.style.opacity).toBe("");
}); expect(element.style.transform).toBe("");
expect(card.whileTap).toEqual({ scale: 0.99 });
} }
}); });
it("path: reduced motion -> cards only fade, with no hover lift and no press feedback", () => { it("path: the four cards are not focus stops and use the CSS press feedback", () => {
jest.mocked(useReducedMotion).mockReturnValue(true); const { container } = render(<About />);
render(<About />); const cards = container.querySelectorAll("article");
const cards = renderedCards();
expect(cards).toHaveLength(4); expect(cards).toHaveLength(4);
for (const card of cards) { for (const card of cards) {
expect(card.variants?.hidden).toEqual({ opacity: 0 }); expect(card).not.toHaveAttribute("tabindex");
expect(card.variants?.visible.transition).toEqual({ duration: 0.25 }); expect(card).toHaveClass("cv-press");
expect(card.whileHover).toBeUndefined();
expect(card.whileTap).toBeUndefined();
} }
}); });
it("path: content does not depend on motion -> same story, profile points and résumé link", () => { it("path: the three story cards lift on hover and press with CSS; the profile card presses only", () => {
for (const reduced of [false, true]) { const { container } = render(<About />);
jest.mocked(useReducedMotion).mockReturnValue(reduced);
const { unmount } = render(<About />); const cards = [...container.querySelectorAll("article")];
expect(cards).toHaveLength(4);
for (const card of cards.slice(0, 3)) {
expect(card).toHaveClass("cv-press", "cv-lift");
}
expect(cards[3]).toHaveClass("cv-press");
});
it("path: the story, the profile points and the résumé link are all there", () => {
render(<About />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
level: 3, level: 3,
name: "Do sinal físico ao produto digital", name: "Você fala direto com quem desenvolve",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Arquitetura pragmática")).toBeInTheDocument(); expect(
screen.getByText("Solução do tamanho do seu problema"),
).toBeInTheDocument();
expect( expect(
screen.getByRole("link", { name: "Ver trajetória completa" }), screen.getByRole("link", { name: "Ver trajetória completa" }),
).toHaveAttribute("href", "/curriculo"); ).toHaveAttribute("href", "/curriculo");
unmount(); });
it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(
/protocolos de baixo nível|operabilidade|camada física|sinal físico/i,
);
expect(
screen.getByText(
"Desde 2022 desenvolvo sistemas internos, aplicativos e integrações IoT na Apolo Energia.",
),
).toBeInTheDocument();
});
it("path: the location label stays on one line and the columns stretch to the same height", () => {
const { container } = render(<About />);
expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass(
"whitespace-nowrap",
);
expect(container.querySelector(".ds-grid-2")).toHaveClass("items-stretch");
const story = container.querySelectorAll("article");
for (const card of Array.from(story).slice(0, 3)) {
expect(card).toHaveClass("flex-1");
} }
}); });
}); });
+63 -94
View File
@@ -1,90 +1,55 @@
"use client";
import { motion, type Variants } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import profilePhoto from "@/public/edev.jpg"; import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine"; import { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage"; import { LazyRevealImage } from "./animations/LazyRevealImage";
import { textLink } from "./button-styles"; import { textLink } from "./button-styles";
import "./conversion.css";
import { Reveal } from "./Reveal";
import { Section } from "./Section"; import { Section } from "./Section";
const STORY_DATA = [ const STORY_DATA = [
{ {
label: "Hardware + software", label: "Como eu trabalho",
title: "Do sinal físico ao produto digital", title: "Você fala direto com quem desenvolve",
content: content:
"Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.", "Você conversa comigo sobre o negócio e acompanha as decisões ao longo da entrega, sem intermediários entre o seu pedido e o sistema.",
}, },
{ {
label: "Decisões técnicas", label: "Sem começar do zero",
title: "Entrega clara mesmo sob restrição", title: "Aproveito o que você já tem",
content: content:
"Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.", "Antes de mexer em um sistema que já está em uso, vejo o que existe e combino com você o que vem primeiro. Nem todo problema exige começar de novo.",
}, },
{ {
label: "Operação", label: "Depois da entrega",
title: "Do código ao sistema no ar", title: "Do código ao sistema no ar",
content: content:
"Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.", "Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, junto com as funcionalidades.",
}, },
]; ];
const PROFILE_POINTS = [ const PROFILE_POINTS = [
{ {
title: "Arquitetura pragmática", title: "Solução do tamanho do seu problema",
description: description:
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.", "Escolho o que resolve o seu caso, sem complicar além do necessário.",
}, },
{ {
title: "Integração profunda", title: "Tudo funcionando junto",
description: description:
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.", "A tela, os dados e os equipamentos são ligados para trabalhar como uma coisa só.",
}, },
{ {
title: "Responsabilidade técnica", title: "Quem responde pelo projeto",
description: description:
"Contato direto com quem define a arquitetura e desenvolve o sistema.", "Você tem contato direto com quem decide a estrutura e desenvolve o sistema.",
}, },
]; ];
/** Atraso (s) de cada cartão: entram em escada, como na vitrine de serviços. */
const cardDelay = (index: number) => 0.08 * (index + 1);
export default function About() { export default function About() {
const shouldReduceMotion = useReducedMotion();
const staggerContainer: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.08,
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],
},
},
};
const cardHover = shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 18px 40px rgba(10,10,10,0.16)",
};
return ( return (
<Section <Section
id="about" id="about"
@@ -93,63 +58,66 @@ export default function About() {
description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega." description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega."
disableChildrenAnimation disableChildrenAnimation
aside={ aside={
<div className="mt-sp-4 ds-metric" data-reel="left"> <div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" /> <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-slash" />
Base em Garuva, SC | Atendimento remoto Garuva, SC · Atendimento remoto
</div> </div>
} }
> >
<motion.div <div className="ds-grid-2 items-stretch">
className="ds-grid-2 items-start" <div className="flex flex-col gap-sp-6">
initial="hidden" {STORY_DATA.map((step, index) => (
whileInView="visible" <Reveal
viewport={{ once: true, amount: 0.2 }}
variants={staggerContainer}
>
<motion.div
className="flex flex-col gap-sp-6"
variants={staggerContainer}
>
{STORY_DATA.map((step) => (
<motion.article
key={step.title} key={step.title}
className="ds-card" y={20}
variants={cardVariants} duration={0.8}
whileHover={cardHover} delay={cardDelay(index)}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} className="flex flex-1 flex-col"
transition={{ type: "spring", stiffness: 180, damping: 18 }}
> >
<article className="ds-card cv-press cv-lift flex-1">
<div className="ds-kicker">{step.label}</div> <div className="ds-kicker">{step.label}</div>
<DrawLine delay={0.03} /> <DrawLine delay={0.03} />
<h3 className="ds-card-title">{step.title}</h3> <h3 className="ds-card-title">{step.title}</h3>
<p className="ds-copy" data-reel="right"> <p className="ds-copy" data-reel="right">
{step.content} {step.content}
</p> </p>
</motion.article> </article>
</Reveal>
))} ))}
</motion.div> </div>
<motion.article <Reveal
className="ds-card h-full" y={20}
variants={cardVariants} duration={0.8}
whileHover={cardHover} delay={cardDelay(STORY_DATA.length)}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} className="h-full"
transition={{ type: "spring", stiffness: 180, damping: 18 }}
> >
<article className="ds-card cv-press cv-lift h-full">
<div className="flex items-center gap-sp-5">
<LazyRevealImage <LazyRevealImage
src={profilePhoto} src={profilePhoto}
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC" alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]" className="aspect-square w-28 shrink-0 overflow-hidden rounded-[6px] border border-[var(--border)] sm:w-32"
imageClassName="object-contain" imageClassName="object-cover"
sizes="128px"
/> />
<div className="ds-kicker">Da arquitetura à implementação</div> <div className="min-w-0">
<div className="ds-kicker">Da ideia ao sistema no ar</div>
<h3 className="ds-card-title !mt-sp-2">
Uma pessoa cuida do sistema inteiro
</h3>
</div>
</div>
<DrawLine delay={0.05} /> <DrawLine delay={0.05} />
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
<p className="ds-copy" data-reel="top"> <p className="ds-copy" data-reel="top">
Meu trabalho conecta interface, serviços, dados e, quando Cuido da tela que sua equipe usa, dos dados por trás dela e,
necessário, equipamentos. Entender essas partes em conjunto ajuda a quando o negócio tem máquinas ou sensores, da ligação deles com o
definir o que construir, o que integrar e o que pode ser sistema. Ver tudo junto ajuda a decidir o que construir, o que
aproveitado. conectar e o que dá para aproveitar.
</p>
<p className="ds-copy">
Desde 2022 desenvolvo sistemas internos, aplicativos e integrações
IoT na Apolo Energia.
</p> </p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom"> <div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => ( {PROFILE_POINTS.map((point) => (
@@ -157,7 +125,7 @@ export default function About() {
key={point.title} key={point.title}
className="border-t border-[var(--border)] pt-sp-4" className="border-t border-[var(--border)] pt-sp-4"
> >
<div className="text-[12px] font-medium tracking-[0.02em]"> <div className="text-[15px] font-medium tracking-[0.02em]">
{point.title} {point.title}
</div> </div>
<p className="ds-copy !mt-sp-2">{point.description}</p> <p className="ds-copy !mt-sp-2">{point.description}</p>
@@ -170,8 +138,9 @@ export default function About() {
> >
Ver trajetória completa Ver trajetória completa
</Link> </Link>
</motion.article> </article>
</motion.div> </Reveal>
</div>
</Section> </Section>
); );
} }
+50
View File
@@ -29,3 +29,53 @@ it("renders nothing without clients", () => {
const withoutProp = render(<ClientStrip />); const withoutProp = render(<ClientStrip />);
expect(withoutProp.container).toBeEmptyDOMElement(); expect(withoutProp.container).toBeEmptyDOMElement();
}); });
it("puts the years of experience in the same line, after the clients", () => {
render(<ClientStrip clients={clients} years={5} />);
const experience = screen.getByText("anos de experiência");
expect(screen.getByText("5")).toBeInTheDocument();
const lastClient = screen.getByRole("link", { name: "Lavação 047" });
expect(
lastClient.compareDocumentPosition(experience) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(experience.closest("div")).toBe(
screen.getByText("Já entregue para:").parentElement,
);
});
it("shows only the experience when there are no clients, and nothing without either", () => {
const only = render(<ClientStrip years={5} />);
expect(screen.queryByText("Já entregue para:")).toBeNull();
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
only.unmount();
const none = render(<ClientStrip years={0} />);
expect(none.container).toBeEmptyDOMElement();
});
it("gives every client link a touch target of at least 44 px (min-h-11)", () => {
render(<ClientStrip clients={clients} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("min-h-11");
}
});
it("keeps the separator after the last client when the years follow, and none when they do not", () => {
const withYears = render(<ClientStrip clients={clients} years={5} />);
const separators = withYears.container.querySelectorAll(
"li [aria-hidden='true']",
);
expect(separators).toHaveLength(clients.length);
withYears.unmount();
// zero anos não escreve um "0" solto nem um ponto a mais
const zero = render(<ClientStrip clients={clients} years={0} />);
expect(
zero.container.querySelectorAll("li [aria-hidden='true']"),
).toHaveLength(clients.length - 1);
expect(zero.container).not.toHaveTextContent(/anos de experiência/);
expect(screen.queryByText("0")).toBeNull();
});
+32 -13
View File
@@ -1,43 +1,62 @@
import Link from "next/link"; import HoverPrefetchLink from "./HoverPrefetchLink";
export type ClientLink = { id: string; name: string }; 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. * Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* Sem clientes, nada é desenhado. * mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
*/ */
export default function ClientStrip({ export default function ClientStrip({
clients = [], clients = [],
years,
className = "", className = "",
}: { }: {
clients?: ClientLink[]; clients?: ClientLink[];
/** Anos de experiência, na mesma linha dos clientes. */
years?: number;
className?: string; className?: string;
}) { }) {
if (clients.length === 0) return null; if (clients.length === 0 && !years) return null;
return ( return (
<div <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()} className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] ${className}`.trim()}
> >
{clients.length > 0 && (
<>
<p>Já entregue para:</p> <p>Já entregue para:</p>
<ul className="flex flex-wrap items-baseline gap-x-sp-3"> <ul className="flex flex-wrap items-center gap-x-sp-3">
{clients.map((client, index) => ( {clients.map((client, index) => (
<li key={client.id} className="flex items-baseline gap-x-sp-3"> <li key={client.id} className="flex items-center gap-x-sp-3">
<Link <HoverPrefetchLink
href={`/cases/${client.id}`} 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)]" className="inline-flex min-h-11 items-center 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} {client.name}
</Link> </HoverPrefetchLink>
{/* o ponto fica no fim do item: numa quebra de linha ele nunca abre a linha seguinte */} {/* o ponto fica no fim do item (e do último cliente, quando vêm os anos depois): numa quebra de linha ele nunca abre a linha seguinte */}
{index < clients.length - 1 && ( {(index < clients.length - 1 || Boolean(years)) && (
<span aria-hidden="true" className="text-[rgba(244,242,237,.4)]"> <span
aria-hidden="true"
className="text-[rgba(244,242,237,.4)]"
>
· ·
</span> </span>
)} )}
</li> </li>
))} ))}
</ul> </ul>
</>
)}
{years ? (
<p>
<span className="font-mono font-semibold text-[var(--slash)]">
{years}
</span>{" "}
anos de experiência
</p>
) : null}
</div> </div>
); );
} }
+17 -14
View File
@@ -12,7 +12,10 @@ beforeEach(() => {
localStorage.setItem("evandro.analytics-consent.v1", "granted"); localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"]; delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn(); window.gtag = jest.fn();
jest.spyOn(window, "open").mockImplementation(() => null); // uma aba aberta de verdade: se devolver null, o Contact cai para location.href
jest
.spyOn(window, "open")
.mockImplementation(() => ({ opener: {} }) as unknown as Window);
}); });
afterEach(() => { afterEach(() => {
@@ -48,7 +51,7 @@ function sentMessage() {
describe("Contact: validate()", () => { describe("Contact: validate()", () => {
it("path: only the name is missing -> only the name is flagged and gets the focus", async () => { it("path: only the name is missing -> only the name is flagged and gets the focus", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { await fill(user, {
email: "[email protected]", email: "[email protected]",
@@ -69,7 +72,7 @@ describe("Contact: validate()", () => {
}); });
it("path: only the e-mail is malformed -> only the e-mail is flagged and gets the focus (not the name)", async () => { 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(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { await fill(user, {
name: "Ana", name: "Ana",
@@ -88,7 +91,7 @@ describe("Contact: validate()", () => {
}); });
it("path: only the message is missing -> only the message is flagged and gets the focus", async () => { it("path: only the message is missing -> only the message is flagged and gets the focus", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { name: "Ana" }); await fill(user, { name: "Ana" });
@@ -105,7 +108,7 @@ describe("Contact: validate()", () => {
}); });
it("path: name and message made only of spaces -> both rejected", async () => { it("path: name and message made only of spaces -> both rejected", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { name: " ", message: " " }); await fill(user, { name: " ", message: " " });
@@ -118,7 +121,7 @@ describe("Contact: validate()", () => {
}); });
it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => { it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { await fill(user, {
name: "Ana", name: "Ana",
@@ -136,7 +139,7 @@ describe("Contact: validate()", () => {
it.each(["a@b", "a@c.", "@c.com", "a@@c.com", "a [email protected]", "sem-arroba.com"])( 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", "path: e-mail %j -> invalid format is blocked",
async (email) => { async (email) => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { name: "Ana", email, message: "Organizar pedidos" }); await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
@@ -150,7 +153,7 @@ describe("Contact: validate()", () => {
it.each(["[email protected]", "[email protected]"])( it.each(["[email protected]", "[email protected]"])(
"path: e-mail %j -> valid format is accepted and sent", "path: e-mail %j -> valid format is accepted and sent",
async (email) => { async (email) => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { name: "Ana", email, message: "Organizar pedidos" }); await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
@@ -165,7 +168,7 @@ describe("Contact: validate()", () => {
describe("Contact: updateField() and the error state", () => { describe("Contact: updateField() and the error state", () => {
it("path: typing in one field clears only that field's error", async () => { it("path: typing in one field clears only that field's error", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await user.click(submitButton()); await user.click(submitButton());
expect(screen.getByText(NAME_ERROR)).toBeVisible(); expect(screen.getByText(NAME_ERROR)).toBeVisible();
@@ -180,7 +183,7 @@ describe("Contact: updateField() and the error state", () => {
}); });
it("path: changing the project type keeps the other fields' errors untouched", async () => { it("path: changing the project type keeps the other fields' errors untouched", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await user.click(submitButton()); await user.click(submitButton());
@@ -197,7 +200,7 @@ describe("Contact: updateField() and the error state", () => {
}); });
it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => { it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await user.click(submitButton()); await user.click(submitButton());
expect(window.open).not.toHaveBeenCalled(); expect(window.open).not.toHaveBeenCalled();
@@ -214,7 +217,7 @@ describe("Contact: updateField() and the error state", () => {
describe("Contact: mensagem montada", () => { describe("Contact: mensagem montada", () => {
it("path: surrounding spaces are trimmed in name, e-mail and message", async () => { it("path: surrounding spaces are trimmed in name, e-mail and message", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await fill(user, { await fill(user, {
name: " Ana ", name: " Ana ",
@@ -233,7 +236,7 @@ describe("Contact: mensagem montada", () => {
}); });
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 () => { 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(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
await user.selectOptions( await user.selectOptions(
screen.getByRole("combobox", { name: /tipo de projeto/i }), screen.getByRole("combobox", { name: /tipo de projeto/i }),
@@ -260,7 +263,7 @@ describe("Contact: mensagem montada", () => {
}); });
it("path: the direct e-mail row reports its own placement", async () => { it("path: the direct e-mail row reports its own placement", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
render(<Contact />); render(<Contact />);
const direct = screen.getByRole("link", { const direct = screen.getByRole("link", {
+242 -17
View File
@@ -1,13 +1,26 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { renderToStaticMarkup } from "react-dom/server";
import Contact from "./Contact"; import Contact from "./Contact";
/**
* Sem atraso entre as teclas: com o atraso padrão, digitar o contexto longo (cerca de 130 caracteres) levava
* mais de 5 s em máquina lenta e o teste estourava o tempo. O texto longo ainda entra por colagem.
*/
const setup = () => userEvent.setup({ delay: null });
/** Aba aberta de verdade; o Contact corta o vínculo com a página (opener = null) logo depois. */
let openedWindow: { opener: unknown };
beforeEach(() => { beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production"); jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted"); localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"]; delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn(); window.gtag = jest.fn();
jest.spyOn(window, "open").mockImplementation(() => null); openedWindow = { opener: {} };
jest
.spyOn(window, "open")
.mockImplementation(() => openedWindow as unknown as Window);
}); });
afterEach(() => { afterEach(() => {
@@ -17,7 +30,7 @@ afterEach(() => {
}); });
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => { it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
const user = userEvent.setup(); const user = setup();
render(<Contact />); render(<Contact />);
const name = "Ana & João"; const name = "Ana & João";
@@ -28,15 +41,15 @@ it("prepares an isolated WhatsApp draft with the entered project details only af
await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email); await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i }); const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
await user.selectOptions(subject, "Integrações e automações"); await user.selectOptions(subject, "Integrações e automações");
await user.type(screen.getByRole("textbox", { name: /contexto/i }), context); await user.click(screen.getByRole("textbox", { name: /contexto/i }));
await user.paste(context);
expect(window.open).not.toHaveBeenCalled(); expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled(); expect(window.gtag).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /whatsapp/i })); await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1); expect(window.open).toHaveBeenCalledTimes(1);
const [destination, target, features] = jest.mocked(window.open).mock const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0];
.calls[0];
const url = new URL(String(destination)); const url = new URL(String(destination));
expect(url.origin).toBe("https://wa.me"); expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d+$/); expect(url.pathname).toMatch(/^\/\d+$/);
@@ -47,7 +60,9 @@ it("prepares an isolated WhatsApp draft with the entered project details only af
expect(message).toContain("Integrações e automações"); expect(message).toContain("Integrações e automações");
expect(message).toContain(context); expect(message).toContain(context);
expect(target).toBe("_blank"); expect(target).toBe("_blank");
expect(features).toBe("noopener,noreferrer"); // sem "noopener" na chamada (ele faria o window.open devolver sempre null); o vínculo é cortado depois
expect(rest).toEqual([]);
expect(openedWindow.opener).toBeNull();
expect(window.gtag).toHaveBeenCalledTimes(1); expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", { expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S", send_to: "G-QTT9QT0T3S",
@@ -60,7 +75,7 @@ it("prepares an isolated WhatsApp draft with the entered project details only af
}); });
it("does not open WhatsApp while required fields are empty or the email is invalid", async () => { it("does not open WhatsApp while required fields are empty or the email is invalid", async () => {
const user = userEvent.setup(); const user = setup();
render(<Contact />); render(<Contact />);
const submit = screen.getByRole("button", { name: /whatsapp/i }); const submit = screen.getByRole("button", { name: /whatsapp/i });
@@ -84,7 +99,7 @@ it("does not open WhatsApp while required fields are empty or the email is inval
it("opens the project draft with analytics unavailable and consent denied", async () => { it("opens the project draft with analytics unavailable and consent denied", async () => {
localStorage.setItem("evandro.analytics-consent.v1", "denied"); localStorage.setItem("evandro.analytics-consent.v1", "denied");
delete window.gtag; delete window.gtag;
const user = userEvent.setup(); const user = setup();
render(<Contact />); render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type( await user.type(
@@ -105,7 +120,7 @@ it("opens the project draft with analytics unavailable and consent denied", asyn
}); });
it("lets the visitor skip the e-mail and leaves that line out of the message", async () => { it("lets the visitor skip the e-mail and leaves that line out of the message", async () => {
const user = userEvent.setup(); const user = setup();
render(<Contact />); render(<Contact />);
expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired(); expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired();
@@ -120,23 +135,31 @@ it("lets the visitor skip the e-mail and leaves that line out of the message", a
const url = new URL(String(jest.mocked(window.open).mock.calls[0][0])); const url = new URL(String(jest.mocked(window.open).mock.calls[0][0]));
const message = url.searchParams.get("text") ?? ""; const message = url.searchParams.get("text") ?? "";
expect(message).toContain("Nome: Ana"); expect(message).toContain("Nome: Ana");
expect(message).toContain("Tipo de projeto: Site para empresa"); expect(message).toContain("Tipo de projeto: Ainda preciso definir");
expect(message).not.toContain("E-mail:"); expect(message).not.toContain("E-mail:");
}); });
it("offers 'Site para empresa' as the first and default project type", () => { it("offers 'Ainda preciso definir' as the first and default project type", () => {
render(<Contact />); render(<Contact />);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i }); const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
const options = Array.from(subject.querySelectorAll("option")).map( const options = Array.from(subject.querySelectorAll("option")).map(
(option) => option.value, (option) => option.value,
); );
expect(options[0]).toBe("Site para empresa"); expect(options[0]).toBe("Ainda preciso definir");
expect(subject).toHaveValue("Site para empresa"); expect(options).toContain("Site para empresa");
expect(subject).toHaveValue("Ainda preciso definir");
});
it("labels the first field 'Seu nome'", () => {
render(<Contact />);
expect(screen.getByLabelText("Seu nome")).toHaveAttribute("name", "name");
expect(screen.queryByLabelText("Nome completo")).toBeNull();
}); });
it("explains each invalid field in Portuguese and marks it for assistive technology", async () => { it("explains each invalid field in Portuguese and marks it for assistive technology", async () => {
const user = userEvent.setup(); const user = setup();
render(<Contact />); render(<Contact />);
await user.type( await user.type(
@@ -171,7 +194,7 @@ it("explains each invalid field in Portuguese and marks it for assistive technol
}); });
it("offers the same text by e-mail, below the WhatsApp button", async () => { it("offers the same text by e-mail, below the WhatsApp button", async () => {
const user = userEvent.setup(); const user = setup();
render(<Contact />); render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
@@ -194,7 +217,7 @@ it("offers the same text by e-mail, below the WhatsApp button", async () => {
const body = url.searchParams.get("body") ?? ""; const body = url.searchParams.get("body") ?? "";
expect(body).toContain("Nome: Ana"); expect(body).toContain("Nome: Ana");
expect(body).toContain("Organizar pedidos & estoque"); expect(body).toContain("Organizar pedidos & estoque");
expect(body).toContain("Tipo de projeto: Site para empresa"); expect(body).toContain("Tipo de projeto: Ainda preciso definir");
await user.click(mail); await user.click(mail);
expect(window.gtag).toHaveBeenCalledWith( expect(window.gtag).toHaveBeenCalledWith(
@@ -214,3 +237,205 @@ it("does not call the direct WhatsApp row 'Contato'", () => {
expect(text).toContain("Quero conversar sobre um projeto."); expect(text).toContain("Quero conversar sobre um projeto.");
expect(text).not.toMatch(/sobre Contato/); expect(text).not.toMatch(/sobre Contato/);
}); });
describe("e-mail draft validation", () => {
it("does not open the e-mail client while the form is empty, and explains what is missing", () => {
render(<Contact />);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
// fireEvent.click devolve false quando o clique foi cancelado (preventDefault)
expect(fireEvent.click(mail)).toBe(false);
expect(
screen.getByText("Informe seu nome para eu saber com quem falo."),
).toBeVisible();
expect(
screen.getByText("Conte em poucas palavras o que você precisa resolver."),
).toBeVisible();
expect(screen.getByRole("textbox", { name: /nome/i })).toHaveFocus();
expect(window.gtag).not.toHaveBeenCalled();
});
it("blocks a malformed e-mail address the same way", async () => {
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
expect(fireEvent.click(mail)).toBe(false);
expect(screen.getByRole("textbox", { name: /e-mail/i })).toHaveFocus();
});
it("lets the click through, and measures it, once the form is valid", async () => {
const user = setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
const mail = screen.getByRole("link", {
name: "Prefere e-mail? Enviar por e-mail",
});
// o clique não é cancelado pela validação: o navegador segue para o mailto:
expect(fireEvent.click(mail)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"email_click",
expect.objectContaining({ placement: "contact_form" }),
);
});
});
describe("WhatsApp fallback", () => {
it("goes to WhatsApp in the same page when the browser blocks the new tab", async () => {
// o jsdom não navega: ele avisa "Not implemented: navigation" ao atribuir location.href
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.mocked(window.open).mockImplementation(() => null);
const user = setup();
render(<Contact />);
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);
expect(
errors.mock.calls.some(([error]) =>
String(error).includes("Not implemented: navigation"),
),
).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
});
it("does not navigate the page when the new tab opened", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const user = setup();
render(<Contact />);
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(errors).not.toHaveBeenCalled();
});
/** Preenche o mínimo e envia; devolve os erros que o jsdom registrou ao "navegar" para location.href. */
async function submitWithMinimumFields() {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const user = setup();
render(<Contact />);
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 }));
return errors;
}
const navigated = (errors: jest.SpyInstance) =>
errors.mock.calls.some(([error]) =>
String(error).includes("Not implemented: navigation"),
);
it("goes to WhatsApp in the same page when there is no window.open", async () => {
const original = window.open;
Object.defineProperty(window, "open", {
configurable: true,
value: undefined,
});
try {
const errors = await submitWithMinimumFields();
expect(navigated(errors)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
} finally {
Object.defineProperty(window, "open", {
configurable: true,
value: original,
});
}
});
it("goes to WhatsApp in the same page when window.open throws", async () => {
jest.mocked(window.open).mockImplementation(() => {
throw new DOMException("bloqueado", "SecurityError");
});
const errors = await submitWithMinimumFields();
expect(navigated(errors)).toBe(true);
});
it("closes the new tab and stays on the same page when its link to this page cannot be cut", async () => {
const close = jest.fn();
const tab = {
close,
get opener() {
return {};
},
set opener(_value: unknown) {
throw new DOMException("sem acesso", "SecurityError");
},
};
jest.mocked(window.open).mockImplementation(() => tab as unknown as Window);
const errors = await submitWithMinimumFields();
expect(close).toHaveBeenCalledTimes(1);
expect(navigated(errors)).toBe(true);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "contact_form" }),
);
});
});
describe("without JavaScript", () => {
it("keeps the form body out of the way and offers direct WhatsApp and e-mail links in a <noscript>", () => {
const html = renderToStaticMarkup(<Contact />);
const noscript = html.match(/<noscript>([\s\S]*?)<\/noscript>/)?.[1] ?? "";
expect(noscript).toMatch(/href="https:\/\/wa\.me\/\d+\?text=/);
expect(noscript).toContain('href="mailto:[email protected]"');
// o botão de envio e os campos ficam na área "js-only", que app/layout.tsx esconde sem JavaScript
const jsOnly = html.match(/class="js-only[^"]*"/);
expect(jsOnly).not.toBeNull();
expect(html.indexOf("js-only")).toBeLessThan(
html.indexOf("Continuar no WhatsApp"),
);
expect(html.indexOf("Continuar no WhatsApp")).toBeLessThan(
html.indexOf("<noscript>"),
);
});
});
+182 -89
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowUpRight, Mail, MapPin } from "lucide-react"; import { ArrowUpRight, Mail, MapPin } from "lucide-react";
import type { FormEvent } from "react"; import type { FormEvent, MouseEvent, ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics"; import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
import { CONTACT_EMAIL } from "@/lib/site-navigation"; import { CONTACT_EMAIL } from "@/lib/site-navigation";
@@ -51,21 +51,117 @@ function buildMailtoUrl(message: string) {
return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent("Projeto pelo site")}&body=${encodeURIComponent(message)}`; return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent("Projeto pelo site")}&body=${encodeURIComponent(message)}`;
} }
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)] aria-[invalid=true]:border-[#ff8a80]";
const labelClassName =
"block font-display text-[15px] md:text-sm font-medium text-white/85";
const errorClassName =
"mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[#ff9d94]";
/** "Ainda preciso definir" vem primeiro e é o padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */
const SUBJECT_OPTIONS = [
"Ainda preciso definir",
"Site para empresa",
"Sistemas web sob medida",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
];
/** Mensagem dos links diretos de quem está sem JavaScript (o formulário não funciona sem ele). */
const DIRECT_MESSAGE =
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.";
/** Abre a URL em outra aba, ou devolve null se não deu (sem `window.open`, bloqueado ou com erro). */
function tryOpenTab(url: string): Window | null {
try {
return window.open(url, "_blank");
} catch {
return null;
}
}
/** Corta o vínculo da aba aberta com esta página; devolve false se o navegador não deixou. */
function detachOpener(tab: Window): boolean {
try {
tab.opener = null;
return true;
} catch {
return false;
}
}
/**
* Abre o WhatsApp em outra aba. Sem "noopener" na chamada, porque com ele o window.open sempre devolve null
* e não dá para saber se abriu; o vínculo com a página (`opener`) é cortado logo em seguida. A página vai para o
* WhatsApp na própria aba quando não há aba nova (pop-up bloqueado, navegador interno de aplicativo, sem
* `window.open`) e também quando o vínculo não pôde ser cortado: nesse caso a aba aberta é fechada, para
* nenhuma aba do WhatsApp ficar com acesso a esta página.
*/
function openWhatsApp(url: string) {
const tab = tryOpenTab(url);
if (tab) {
if (detachOpener(tab)) return;
tab.close();
}
window.location.href = url;
}
/** Atributos que o campo entrega ao controle (input, textarea ou select) para o erro ser anunciado. */
interface ControlProps {
id: string;
"aria-invalid"?: true;
"aria-describedby"?: string;
}
/** Rótulo, controle e mensagem de erro de um campo; o controle recebe o id e os atributos de acessibilidade. */
function Field({
id,
label,
error,
describedBy,
children,
}: {
id: string;
label: string;
error?: string;
/** Outros textos que descrevem o campo, depois do erro. */
describedBy?: string;
children: (control: ControlProps) => ReactNode;
}) {
const errorId = `${id}-error`;
const description = [error ? errorId : "", describedBy ?? ""]
.filter(Boolean)
.join(" ");
return (
<div>
<label htmlFor={id} className={labelClassName}>
{label}
</label>
{children({
id,
"aria-invalid": error ? true : undefined,
"aria-describedby": description || undefined,
})}
{error && (
<p id={errorId} className={errorClassName}>
{error}
</p>
)}
</div>
);
}
export default function Contact() { export default function Contact() {
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp"); const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => { useEffect(() => {
setWhatsAppDisplay(getWhatsAppDisplayNumber()); setWhatsAppDisplay(getWhatsAppDisplayNumber());
}, []); }, []);
const fieldClassName =
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] 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({ const [formData, setFormData] = useState({
name: "", name: "",
email: "", email: "",
subject: "Site para empresa", subject: "Ainda preciso definir",
message: "", message: "",
}); });
const [errors, setErrors] = useState<FieldErrors>({}); const [errors, setErrors] = useState<FieldErrors>({});
@@ -77,22 +173,31 @@ export default function Contact() {
} }
}; };
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { /** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
e.preventDefault(); const hasInvalidField = (form: HTMLFormElement | null) => {
const form = e.currentTarget;
const found = validate(formData); const found = validate(formData);
setErrors(found); setErrors(found);
const firstInvalid = FIELD_ORDER.find((field) => found[field]); const firstInvalid = FIELD_ORDER.find((field) => found[field]);
if (firstInvalid) { if (firstInvalid) {
form.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus(); form?.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
}
return firstInvalid !== undefined;
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (hasInvalidField(e.currentTarget)) return;
openWhatsApp(buildWhatsAppUrl(buildProjectMessage(formData)));
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
};
/** O rascunho por e-mail passa pela mesma validação do WhatsApp: sem ela o e-mail sairia vazio. */
const handleEmailClick = (e: MouseEvent<HTMLAnchorElement>) => {
if (hasInvalidField(e.currentTarget.closest("form"))) {
e.preventDefault();
return; return;
} }
window.open( trackEmailClick({ placement: "contact_form" });
buildWhatsAppUrl(buildProjectMessage(formData)),
"_blank",
"noopener,noreferrer",
);
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
}; };
return ( return (
@@ -119,7 +224,7 @@ export default function Contact() {
<h3 className="font-display text-lg font-semibold text-white"> <h3 className="font-display text-lg font-semibold text-white">
Você fala direto com quem desenvolve. Você fala direto com quem desenvolve.
</h3> </h3>
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65"> <p className="mt-sp-2 max-w-[44ch] font-display text-[15px] md:text-sm leading-7 text-white/65">
Sou Evandro. A primeira conversa é para entender sua necessidade; Sou Evandro. A primeira conversa é para entender sua necessidade;
escopo, prazo e proposta vêm depois. escopo, prazo e proposta vêm depois.
</p> </p>
@@ -133,10 +238,10 @@ export default function Contact() {
<WhatsAppMark className="h-6 w-6" /> <WhatsAppMark className="h-6 w-6" />
</span> </span>
<span className="min-w-0 flex-1 font-display"> <span className="min-w-0 flex-1 font-display">
<span className="block text-sm font-semibold text-white group-hover:text-[#25d366]"> <span className="block text-[15px] md:text-sm font-semibold text-white group-hover:text-[#25d366]">
Prefere conversar direto? Prefere conversar direto?
</span> </span>
<span className="mt-1 block text-sm text-white/65"> <span className="mt-1 block text-[15px] md:text-sm text-white/65">
WhatsApp · {whatsAppDisplay} WhatsApp · {whatsAppDisplay}
</span> </span>
</span> </span>
@@ -151,7 +256,7 @@ export default function Contact() {
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)]" 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" /> <Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-all font-display text-sm"> <span className="min-w-0 break-all font-display text-[15px] md:text-sm">
{CONTACT_EMAIL} {CONTACT_EMAIL}
</span> </span>
<ArrowUpRight <ArrowUpRight
@@ -160,7 +265,7 @@ export default function Contact() {
/> />
</a> </a>
</div> </div>
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-sm leading-6 text-white/55"> <p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] md:text-sm leading-6 text-white/55">
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" /> <MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil. Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
</p> </p>
@@ -183,117 +288,85 @@ export default function Contact() {
> >
Prepare sua mensagem Prepare sua mensagem
</h3> </h3>
<p className="mt-1 font-display text-sm leading-6 text-white/65"> <p className="mt-1 font-display text-[15px] md:text-sm leading-6 text-white/65">
Você revisa no WhatsApp antes de enviar. Você revisa no WhatsApp antes de enviar.
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-sp-5"> {/* js-only: app/layout.tsx esconde o que depende de JavaScript quando ele está desligado, e o <noscript> abaixo entra no lugar */}
<div className="js-only space-y-sp-5">
<div className="grid gap-sp-5 xl:grid-cols-2"> <div className="grid gap-sp-5 xl:grid-cols-2">
<div> <Field id="contact-name" label="Seu nome" error={errors.name}>
<label htmlFor="contact-name" className={labelClassName}> {(control) => (
Nome completo
</label>
<input <input
id="contact-name" {...control}
name="name" name="name"
required required
type="text" type="text"
autoComplete="name" autoComplete="name"
placeholder="Como posso te chamar?" placeholder="Como posso te chamar?"
aria-invalid={errors.name ? true : undefined}
aria-describedby={
errors.name ? "contact-name-error" : undefined
}
value={formData.name} value={formData.name}
onChange={(e) => updateField("name", e.target.value)} onChange={(e) => updateField("name", e.target.value)}
className={fieldClassName} className={fieldClassName}
/> />
{errors.name && (
<p id="contact-name-error" className={errorClassName}>
{errors.name}
</p>
)} )}
</div> </Field>
<div> <Field
<label htmlFor="contact-email" className={labelClassName}>
E-mail (opcional)
</label>
<input
id="contact-email" id="contact-email"
label="E-mail (opcional)"
error={errors.email}
>
{(control) => (
<input
{...control}
name="email" name="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
placeholder="[email protected]" placeholder="[email protected]"
aria-invalid={errors.email ? true : undefined}
aria-describedby={
errors.email ? "contact-email-error" : undefined
}
value={formData.email} value={formData.email}
onChange={(e) => updateField("email", e.target.value)} onChange={(e) => updateField("email", e.target.value)}
className={fieldClassName} className={fieldClassName}
/> />
{errors.email && (
<p id="contact-email-error" className={errorClassName}>
{errors.email}
</p>
)} )}
</Field>
</div> </div>
</div> <Field id="contact-subject" label="Tipo de projeto">
<div> {(control) => (
<label htmlFor="contact-subject" className={labelClassName}>
Tipo de projeto
</label>
<select <select
id="contact-subject" {...control}
name="subject" name="subject"
value={formData.subject} value={formData.subject}
onChange={(e) => updateField("subject", e.target.value)} onChange={(e) => updateField("subject", e.target.value)}
className={`${fieldClassName} [color-scheme:dark]`} className={`${fieldClassName} [color-scheme:dark]`}
> >
<option value="Site para empresa">Site para empresa</option> {SUBJECT_OPTIONS.map((option) => (
<option value="Sistemas web sob medida"> <option key={option} value={option}>
Sistemas web sob medida {option}
</option>
<option value="Aplicativos">Aplicativos</option>
<option value="Integrações e automações">
Integrações e automações
</option>
<option value="Evolução de um sistema existente">
Evolução de um sistema existente
</option>
<option value="Ainda preciso definir">
Ainda preciso definir
</option> </option>
))}
</select> </select>
</div> )}
<div> </Field>
<label htmlFor="contact-message" className={labelClassName}> <Field
Contexto do projeto
</label>
<textarea
id="contact-message" id="contact-message"
label="Contexto do projeto"
error={errors.message}
describedBy="contact-privacy-note"
>
{(control) => (
<textarea
{...control}
name="message" name="message"
required required
rows={4} rows={4}
autoComplete="off" autoComplete="off"
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?" placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
aria-invalid={errors.message ? true : undefined}
aria-describedby={
errors.message
? "contact-message-error contact-privacy-note"
: "contact-privacy-note"
}
value={formData.message} value={formData.message}
onChange={(e) => updateField("message", e.target.value)} onChange={(e) => updateField("message", e.target.value)}
className={`${fieldClassName} min-h-32 resize-y`} className={`${fieldClassName} min-h-32 resize-y`}
/> />
{errors.message && (
<p id="contact-message-error" className={errorClassName}>
{errors.message}
</p>
)} )}
</div> </Field>
<button <button
type="submit" type="submit"
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" 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"
@@ -305,7 +378,7 @@ export default function Contact() {
<p className="text-center font-display text-[15px] leading-6 text-white/70"> <p className="text-center font-display text-[15px] leading-6 text-white/70">
<a <a
href={buildMailtoUrl(buildProjectMessage(formData))} href={buildMailtoUrl(buildProjectMessage(formData))}
onClick={() => trackEmailClick({ placement: "contact_form" })} onClick={handleEmailClick}
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)]" 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 Prefere e-mail? Enviar por e-mail
@@ -313,13 +386,33 @@ export default function Contact() {
</p> </p>
<p <p
id="contact-privacy-note" id="contact-privacy-note"
className="font-display text-xs leading-6 text-white/55" className="font-display text-[15px] md:text-xs leading-6 text-white/55"
> >
Nada é enviado automaticamente. O WhatsApp abre com a mensagem Nada é enviado automaticamente. O WhatsApp abre com a mensagem
para você revisar. Não inclua senhas nem dados pessoais de para você revisar. Não inclua senhas nem dados pessoais de
clientes. clientes.
</p> </p>
</div> </div>
{/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}
<noscript>
<p className="font-display text-base leading-7 text-white/80">
Para enviar sua mensagem, fale direto comigo:
</p>
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
<a
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
>
Conversar no WhatsApp
</a>
<a
href={`mailto:${CONTACT_EMAIL}`}
className="inline-flex min-h-12 items-center justify-center break-all rounded-lg border border-white/25 px-sp-4 font-medium text-white no-underline"
>
{CONTACT_EMAIL}
</a>
</p>
</noscript>
</form> </form>
</div> </div>
</section> </section>
@@ -0,0 +1,108 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
/**
* Testes de caminhos-base de openWhatsApp, dentro de Contact: a aba abriu (o vínculo com a página é cortado),
* o navegador bloqueou (devolve null ou undefined e a própria página vai para o WhatsApp) e os argumentos da chamada.
* O envio com campos inválidos não abre nada.
*/
const NAVIGATION_ERROR = "Not implemented: navigation";
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();
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
async function fillValidForm() {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
return user;
}
const submit = () => screen.getByRole("button", { name: /whatsapp/i });
function navigationAttempts(errors: jest.SpyInstance) {
return errors.mock.calls.filter(([error]) =>
String(error).includes(NAVIGATION_ERROR),
).length;
}
describe("Contact: abertura do WhatsApp", () => {
it("path: the tab opened -> the opener link is cut and this page stays where it is", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
const opened = { opener: { stillLinked: true } };
jest
.spyOn(window, "open")
.mockImplementation(() => opened as unknown as Window);
const user = await fillValidForm();
await user.click(submit());
expect(opened.opener).toBeNull();
expect(navigationAttempts(errors)).toBe(0);
});
it("path: the call asks for a new tab with the wa.me link and no third argument (noopener would hide the result)", async () => {
jest.spyOn(window, "open").mockImplementation(() => null);
jest.spyOn(console, "error").mockImplementation(() => {});
const user = await fillValidForm();
await user.click(submit());
expect(window.open).toHaveBeenCalledTimes(1);
const args = jest.mocked(window.open).mock.calls[0];
expect(args).toHaveLength(2);
expect(String(args[0])).toMatch(/^https:\/\/wa\.me\/\d+\?text=/);
expect(args[1]).toBe("_blank");
});
it("path: the browser blocks the tab with null -> the page itself goes to WhatsApp, once", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(window, "open").mockImplementation(() => null);
const user = await fillValidForm();
await user.click(submit());
expect(navigationAttempts(errors)).toBe(1);
});
it("path: the browser answers undefined (some in-app browsers) -> the page itself goes to WhatsApp", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest
.spyOn(window, "open")
.mockImplementation(() => undefined as unknown as Window);
const user = await fillValidForm();
await user.click(submit());
expect(navigationAttempts(errors)).toBe(1);
});
it("path: the form is invalid -> no tab, no navigation and no metric", async () => {
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(window, "open").mockImplementation(() => null);
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.click(submit());
expect(window.open).not.toHaveBeenCalled();
expect(navigationAttempts(errors)).toBe(0);
expect(window.gtag).not.toHaveBeenCalled();
});
});
+4 -2
View File
@@ -25,12 +25,14 @@ it("is the short home version and points to /ia for the full content", () => {
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "Já começou com IA? Dá para partir daí.", name: "Fez um app com IA? Eu reviso.",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("link", { name: "Entender como funciona" }), screen.getByRole("link", { name: "Ver a revisão de app feito com IA" }),
).toHaveAttribute("href", "/ia"); ).toHaveAttribute("href", "/ia");
// a oferta não promete prazo nem preço
expect(screen.queryByText(/R\$|prazo de|dias/)).toBeNull();
// os incidentes e a LGPD ficam só em /ia // os incidentes e a LGPD ficam só em /ia
expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull(); expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull();
}); });
+4 -4
View File
@@ -2,18 +2,18 @@ import Link from "next/link";
import { secondaryButtonLight } from "./button-styles"; import { secondaryButtonLight } from "./button-styles";
import { Section } from "./Section"; import { Section } from "./Section";
/** Versão curta da home. O conteúdo completo (e os incidentes citados) fica em /ia. */ /** Versão curta da home, perto do fim da página: a oferta de revisão de app feito com IA. O conteúdo completo e os incidentes citados ficam em /ia. */
export default function EngineeringWithAI() { export default function EngineeringWithAI() {
return ( return (
<Section <Section
id="engineering-ai" id="engineering-ai"
eyebrow="IA no desenvolvimento" eyebrow="IA no desenvolvimento"
title="Já começou com IA? Dá para partir daí." title="Fez um app com IA? Eu reviso."
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." description="As ferramentas de IA ajudam a tirar a ideia do papel. Antes de colocar clientes e dados dentro, avalio o que pode ser aproveitado, quais falhas de acesso a dados precisam de correção e o que falta para ir à produção."
disableChildrenAnimation disableChildrenAnimation
> >
<Link href="/ia" className={secondaryButtonLight}> <Link href="/ia" className={secondaryButtonLight}>
Entender como funciona Ver a revisão de app feito com IA
</Link> </Link>
</Section> </Section>
); );
@@ -0,0 +1,128 @@
import { act, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import type { WhatsAppContexts } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
/**
* O botão vive em app/layout.tsx e vale em todo o site: a mensagem acompanha a página aberta, e a observação
* do hero e do contato (que só existem na home) recomeça a cada troca de página.
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
type Entry = Pick<IntersectionObserverEntry, "target" | "isIntersecting">;
const instances: FakeObserver[] = [];
const originalObserver = global.IntersectionObserver;
class FakeObserver {
disconnect = jest.fn();
constructor(private callback: IntersectionObserverCallback) {
instances.push(this);
}
observe = jest.fn();
unobserve = jest.fn();
takeRecords = () => [];
report(entries: Entry[]) {
act(() =>
this.callback(entries as IntersectionObserverEntry[], this as never),
);
}
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
const messageOf = (link: HTMLElement | null) =>
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
beforeEach(() => {
instances.length = 0;
global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
global.IntersectionObserver = originalObserver;
document.getElementById("hero-contact-cta")?.remove();
document.getElementById("contact")?.remove();
});
describe("message by route", () => {
it.each([
[
"/servicos/criacao-de-sites",
"Quero conversar sobre Sites profissionais.",
],
[
"/cases/sev-modaintima",
"Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.",
],
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
["/", "Quero conversar sobre um projeto."],
["/servicos", "Quero conversar sobre um projeto."],
["/servicos/inexistente", "Quero conversar sobre um projeto."],
])("on %s", (pathname, sentence) => {
jest.mocked(usePathname).mockReturnValue(pathname);
// sem hero nem contato na página, o botão aparece de imediato
render(<FloatingWhatsApp contexts={contexts} />);
expect(messageOf(floatingLink())).toContain(sentence);
});
it("falls back to a generic message without contexts", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
render(<FloatingWhatsApp />);
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre um projeto.",
);
});
});
describe("page changes", () => {
it("observes the hero and the contact again on every page, hiding the button until the new report", () => {
jest.mocked(usePathname).mockReturnValue("/cases/sev-modaintima");
const { rerender } = render(<FloatingWhatsApp contexts={contexts} />);
// páginas de case não têm hero nem contato: o botão está à vista
expect(floatingLink()).toBeInTheDocument();
expect(instances).toHaveLength(0);
// volta à home, que tem os dois convites
document.body.insertAdjacentHTML(
"beforeend",
'<a id="hero-contact-cta" href="/#contact">Conversar</a><section id="contact"></section>',
);
jest.mocked(usePathname).mockReturnValue("/");
rerender(<FloatingWhatsApp contexts={contexts} />);
// sem piscar: some até o observador dizer que o hero saiu da tela
expect(floatingLink()).toBeNull();
expect(instances).toHaveLength(1);
const hero = document.getElementById("hero-contact-cta") as Element;
const contact = document.getElementById("contact") as Element;
instances[0].report([
{ target: hero, isIntersecting: false },
{ target: contact, isIntersecting: false },
]);
expect(floatingLink()).toBeInTheDocument();
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre um projeto.",
);
// sai da home para um serviço: o observador antigo é desligado e o botão passa a falar do serviço
document.getElementById("hero-contact-cta")?.remove();
document.getElementById("contact")?.remove();
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
rerender(<FloatingWhatsApp contexts={contexts} />);
expect(instances[0].disconnect).toHaveBeenCalled();
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre Sites profissionais.",
);
});
});
+18 -4
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
import WhatsAppLink from "./WhatsAppLink"; import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark"; import { WhatsAppMark } from "./WhatsAppMark";
@@ -9,17 +11,28 @@ export const HERO_CTA_ID = "hero-contact-cta";
const CONTACT_SECTION_ID = "contact"; 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 * Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
* à vista e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta. * cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
*/ */
export default function FloatingWhatsApp() { export default function FloatingWhatsApp({
contexts,
}: {
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx). */
contexts?: WhatsAppContexts;
}) {
const pathname = usePathname();
const subject = whatsAppSubjectForPath(pathname, contexts);
// null até o observador responder pela primeira vez: assim o botão não pisca na chegada, quando o hero está à vista // 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< const [inviteVisible, setInviteVisible] = useState<Record<
string, string,
boolean boolean
> | null>(null); > | null>(null);
// o botão vive no layout: a cada página o hero e o contato (que só existem na home) entram ou saem de cena
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só reinicia a observação a cada página
useEffect(() => { useEffect(() => {
setInviteVisible(null);
const targets = [HERO_CTA_ID, CONTACT_SECTION_ID] const targets = [HERO_CTA_ID, CONTACT_SECTION_ID]
.map((id) => document.getElementById(id)) .map((id) => document.getElementById(id))
.filter((element): element is HTMLElement => element !== null); .filter((element): element is HTMLElement => element !== null);
@@ -39,7 +52,7 @@ export default function FloatingWhatsApp() {
}); });
for (const target of targets) observer.observe(target); for (const target of targets) observer.observe(target);
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, [pathname]);
if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null; if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null;
@@ -47,6 +60,7 @@ export default function FloatingWhatsApp() {
<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"> <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 <WhatsAppLink
placement="floating" placement="floating"
service={subject}
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" 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" /> <WhatsAppMark className="h-7 w-7" />
+13
View File
@@ -22,6 +22,16 @@ describe("Footer contact and navigation", () => {
expect(emailLink).not.toHaveAttribute("target", "_blank"); expect(emailLink).not.toHaveAttribute("target", "_blank");
}); });
it("offers WhatsApp in the contact column, in a new tab", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const whatsApp = within(contact).getByRole("link", { name: "WhatsApp" });
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
});
it("lists the six services from the services hub as same-tab links", () => { it("lists the six services from the services hub as same-tab links", () => {
render(<Footer serviceLinks={serviceHubLinks} />); render(<Footer serviceLinks={serviceHubLinks} />);
@@ -45,6 +55,9 @@ describe("Footer contact and navigation", () => {
"href", "href",
"/cases", "/cases",
); );
expect(
within(more).getByRole("link", { name: "Revisão de app feito com IA" }),
).toHaveAttribute("href", "/ia");
expect( expect(
within(more).getByRole("link", { name: "Privacidade" }), within(more).getByRole("link", { name: "Privacidade" }),
).toHaveAttribute("href", "/privacidade"); ).toHaveAttribute("href", "/privacidade");
+23 -6
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/site-navigation"; } from "@/lib/site-navigation";
import "./conversion.css"; import "./conversion.css";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const headingClassName = const headingClassName =
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]"; "font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
@@ -30,7 +31,8 @@ export default function Footer({
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
<footer className="border-t border-[var(--border)] bg-[var(--paper)] px-[clamp(20px,6vw,64px)] py-sp-10 text-[var(--ink)]"> // no celular o botão flutuante do WhatsApp (56 px + 16 px de margem) cobriria a última linha: o rodapé sobe o espaço dele
<footer className="border-t border-[var(--border)] bg-[var(--paper)] px-[clamp(20px,6vw,64px)] py-sp-10 text-[var(--ink)] max-md:pb-[calc(var(--sp-10)+72px)]">
<div className="ds-section-shell"> <div className="ds-section-shell">
<div className="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10"> <div className="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10">
<section aria-labelledby="footer-contact-title"> <section aria-labelledby="footer-contact-title">
@@ -38,6 +40,14 @@ export default function Footer({
Contato Contato
</p> </p>
<ul className="mt-sp-2"> <ul className="mt-sp-2">
<li>
<WhatsAppLink
placement="footer"
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
>
WhatsApp
</WhatsAppLink>
</li>
<li> <li>
<a <a
href={`mailto:${CONTACT_EMAIL}`} href={`mailto:${CONTACT_EMAIL}`}
@@ -100,6 +110,11 @@ export default function Footer({
Cases Cases
</Link> </Link>
</li> </li>
<li>
<Link href="/ia" className={linkClassName}>
Revisão de app feito com IA
</Link>
</li>
<li> <li>
<Link href="/privacidade" className={linkClassName}> <Link href="/privacidade" className={linkClassName}>
Privacidade Privacidade
@@ -117,7 +132,7 @@ export default function Footer({
<li> <li>
<Link <Link
href="/curriculo" href="/curriculo"
className={`${linkClassName} !text-[14px] !text-[var(--meta)]`} className={`${linkClassName} !text-[15px] md:!text-[14px] !text-[var(--meta)]`}
> >
Currículo Currículo
</Link> </Link>
@@ -127,14 +142,16 @@ export default function Footer({
</div> </div>
<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"> <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 <Link
href="/#home" href="/#home"
aria-label="Evandro.dev.br — início" aria-label="Evandro.dev.br — início"
className="inline-flex min-h-11 items-center" className="inline-flex min-h-11 items-center"
> >
<Logo theme="light" className="w-40" /> {/* o ".dev" do logo é limão (#ccff00), quase invisível sobre o papel: o filtro o escurece (de 1,04:1
</a> para 4:1) e o resto do logo, já quase preto, quase não muda */}
<p className="font-display text-[14px] leading-6 text-[var(--meta)]"> <Logo theme="light" className="w-40 [filter:brightness(0.5)]" />
</Link>
<p className="font-display text-[15px] md:text-[14px] leading-6 text-[var(--meta)]">
© {currentYear} Evandro Fonseca Junior. Todos os direitos © {currentYear} Evandro Fonseca Junior. Todos os direitos
reservados. reservados.
</p> </p>
+2 -2
View File
@@ -234,7 +234,7 @@ function GoogleAnalyticsConsent() {
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6"> <div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
<p <p
id="analytics-consent-description" id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-sm" className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85 sm:text-sm"
> >
Usamos métricas de navegação do Google só se você permitir.{" "} Usamos métricas de navegação do Google só se você permitir.{" "}
<a <a
@@ -273,7 +273,7 @@ function GoogleAnalyticsConsent() {
<p <p
role="alert" role="alert"
aria-label="Falha ao salvar o consentimento" aria-label="Falha ao salvar o consentimento"
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[13px] leading-5 text-[#ff9d94]" className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] md:text-[13px] leading-5 text-[#ff9d94]"
> >
{consentError} {consentError}
</p> </p>
+154
View File
@@ -0,0 +1,154 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { Header } from "./Header";
/**
* Testes de caminhos-base de Header (complemento de Header.test.tsx): a barra que escurece com a rolagem,
* as teclas do menu do celular que não o fecham e a rota ausente (fora do roteador).
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
function setScrollY(value: number) {
Object.defineProperty(window, "scrollY", {
value,
configurable: true,
writable: true,
});
}
const header = () => screen.getByRole("banner");
const isDark = () => header().className.includes("backdrop-blur-md");
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
setScrollY(0);
});
afterEach(() => {
jest.restoreAllMocks();
setScrollY(0);
});
describe("Header: rolagem", () => {
it("path: page at the top -> transparent border and solid background", () => {
render(<Header />);
expect(isDark()).toBe(false);
expect(header()).toHaveClass("border-transparent");
});
it("path: scrolling down and back to the top -> blurred bar, then the plain one again", () => {
render(<Header />);
setScrollY(120);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(isDark()).toBe(true);
expect(header()).not.toHaveClass("border-transparent");
// outro evento de rolagem sem mudar de lado: nada muda
setScrollY(300);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(isDark()).toBe(true);
setScrollY(0);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(isDark()).toBe(false);
});
it("path: page already scrolled when the header mounts -> starts with the blurred bar", () => {
setScrollY(500);
render(<Header />);
expect(isDark()).toBe(true);
});
it("path: unmount -> the scroll listener is removed", () => {
const remove = jest.spyOn(window, "removeEventListener");
const { unmount } = render(<Header />);
unmount();
expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function));
});
});
describe("Header: teclado no menu do celular", () => {
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const menu = screen.getByRole("navigation", { name: "Navegação mobile" });
// fireEvent devolve false só quando o evento foi cancelado (preventDefault)
expect(fireEvent.keyDown(menu, { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(menu, { key: "Enter" })).toBe(true);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).toBeInTheDocument();
});
it("path: Escape on the menu button while the menu is open -> closes it and keeps the focus on the button", () => {
render(<Header />);
const button = screen.getByRole("button", { name: "Abrir menu" });
fireEvent.click(button);
act(() => button.focus());
expect(fireEvent.keyDown(button, { key: "Escape" })).toBe(false);
const closed = screen.getByRole("button", { name: "Abrir menu" });
expect(closed).toHaveAttribute("aria-expanded", "false");
expect(closed).toHaveFocus();
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
});
it("path: Escape while the menu is already closed -> nothing opens", () => {
render(<Header />);
const button = screen.getByRole("button", { name: "Abrir menu" });
fireEvent.keyDown(button, { key: "Escape" });
expect(button).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
});
});
describe("Header: rota ausente", () => {
it("path: usePathname gives null (outside the router) -> the header button goes to the home form", () => {
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
render(<Header />);
expect(
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
).toHaveAttribute("href", "/#contact");
});
});
+151 -8
View File
@@ -1,8 +1,40 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import type { WhatsAppContexts } from "@/lib/whatsapp";
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation"; import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
import { Header } from "./Header"; import { Header } from "./Header";
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-next-link="" data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
});
function whatsAppHref(link: HTMLElement) {
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
}
function HomeWithHeader() { function HomeWithHeader() {
const onLayoutReady = useHomeFragmentNavigation(); const onLayoutReady = useHomeFragmentNavigation();
useEffect(() => { useEffect(() => {
@@ -32,12 +64,16 @@ describe("Header", () => {
within(navigation) within(navigation)
.getAllByRole("link") .getAllByRole("link")
.map((link) => link.getAttribute("href")), .map((link) => link.getAttribute("href")),
).toEqual(["/#projects", "/#process", "/#about", "/cases", "/servicos"]); ).toEqual(["/cases", "/servicos", "/#process", "/#about", "/ia"]);
expect( expect(
within(navigation) within(navigation)
.getAllByRole("link") .getAllByRole("link")
.map((link) => link.textContent), .map((link) => link.textContent),
).toEqual(["Projetos", "Como trabalho", "Sobre", "Cases", "Serviços"]); ).toEqual(["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]);
// "Projetos" saiu: duplicava "Cases"
expect(
within(navigation).queryByRole("link", { name: "Projetos" }),
).not.toBeInTheDocument();
expect( expect(
within(navigation).queryByRole("link", { name: "Experiência" }), within(navigation).queryByRole("link", { name: "Experiência" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -77,11 +113,11 @@ describe("Header", () => {
.getAllByRole("link") .getAllByRole("link")
.map((link) => link.getAttribute("href")), .map((link) => link.getAttribute("href")),
).toEqual([ ).toEqual([
"/#projects",
"/#process",
"/#about",
"/cases", "/cases",
"/servicos", "/servicos",
"/#process",
"/#about",
"/ia",
"/#contact", "/#contact",
]); ]);
expect( expect(
@@ -93,7 +129,7 @@ describe("Header", () => {
const mobileLink = within(mobileNavigation).getByRole("link", { const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre", name: "Sobre",
}); });
mobileLink.focus(); act(() => mobileLink.focus());
fireEvent.keyDown(mobileLink, { key: "Escape" }); fireEvent.keyDown(mobileLink, { key: "Escape" });
const openButton = screen.getByRole("button", { name: "Abrir menu" }); const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false"); expect(openButton).toHaveAttribute("aria-expanded", "false");
@@ -118,7 +154,7 @@ describe("Header", () => {
.find((link) => link.getAttribute("href") === href); .find((link) => link.getAttribute("href") === href);
if (!mobileLink) if (!mobileLink)
throw new Error(`Missing navigation destination: ${href}`); throw new Error(`Missing navigation destination: ${href}`);
mobileLink.focus(); act(() => mobileLink.focus());
fireEvent.click(mobileLink); fireEvent.click(mobileLink);
expect( expect(
@@ -130,4 +166,111 @@ describe("Header", () => {
expect(screen.getByRole("region", { name: destination })).toHaveFocus(); expect(screen.getByRole("region", { name: destination })).toHaveFocus();
}, },
); );
it("makes the mobile menu a full-screen opaque panel with large links and the contact button at the bottom", () => {
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
const panel = mobileNavigation.parentElement?.parentElement as HTMLElement;
expect(panel).toHaveClass("bg-[var(--ink)]");
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
const links = within(mobileNavigation).getAllByRole("link");
for (const link of links.slice(0, 5)) {
expect(link).toHaveClass("text-[22px]", "min-h-14");
}
const contact = links[5];
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
expect(contact.parentElement).toHaveClass("mt-auto");
});
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
render(<Header />);
const navigation = screen.getByRole("navigation", {
name: "Navegação principal",
});
const internal = [
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
...within(navigation).getAllByRole("link"),
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
];
for (const link of internal) {
expect(link).toHaveAttribute("data-next-link");
expect(link.getAttribute("href")?.startsWith("/")).toBe(true);
}
// cabeçalho e logotipo: nada pré-carregado na abertura da página
const headerLinks = internal.slice(0, 1 + 5);
for (const link of headerLinks) {
expect(link).toHaveAttribute("data-prefetch", "false");
}
fireEvent.mouseEnter(headerLinks[1]);
expect(headerLinks[1]).toHaveAttribute("data-prefetch", "null");
expect(headerLinks[2]).toHaveAttribute("data-prefetch", "false");
fireEvent.focus(headerLinks[3]);
expect(headerLinks[3]).toHaveAttribute("data-prefetch", "null");
});
it("sends the header button to the home form on the home and on other pages", () => {
for (const pathname of ["/", "/cases", "/ia", "/servicos"]) {
jest.mocked(usePathname).mockReturnValue(pathname);
const { unmount } = render(<Header whatsAppContexts={contexts} />);
const contact = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contact).toHaveLength(1);
expect(contact[0]).toHaveAttribute("href", "/#contact");
unmount();
}
});
it("opens WhatsApp with the service in the header button on a service page", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(
"Quero conversar sobre Sites profissionais.",
);
});
it("opens WhatsApp with the case in the header button on a case page, also from the mobile menu", () => {
jest.mocked(usePathname).mockReturnValue("/cases/sev-modaintima");
render(<Header whatsAppContexts={contexts} />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const buttons = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
});
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(whatsAppHref(button)).toContain(
"Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.",
);
}
// abrir o WhatsApp pelo menu fecha o menu
fireEvent.click(buttons[1]);
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
});
it("falls back to a generic message on a service page the contexts do not know", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/inexistente");
render(<Header whatsAppContexts={contexts} />);
expect(
whatsAppHref(
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
),
).toContain("Quero conversar sobre um projeto.");
});
}); });
+82 -33
View File
@@ -1,13 +1,66 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type KeyboardEvent, useEffect, useRef, useState } from "react"; import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { NAVIGATION_ITEMS } from "@/lib/site-navigation"; import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
import MagneticButton from "./cinematic/MagneticButton"; import {
isServiceOrCasePath,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "@/lib/whatsapp";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu"; const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
export function Header() { /**
* Botão de contato do cabeçalho. Nas páginas de serviço e de case ele abre o WhatsApp já com o assunto da página;
* nas demais leva ao formulário da home.
*/
function ContactCta({
className,
placement,
viaWhatsApp,
subject,
onNavigate,
}: {
className: string;
placement: string;
viaWhatsApp: boolean;
subject?: string;
onNavigate?: () => void;
}) {
if (viaWhatsApp) {
return (
<WhatsAppLink
className={className}
service={subject}
placement={placement}
onClick={onNavigate}
>
{CTA_LABEL}
</WhatsAppLink>
);
}
return (
<Link href="/#contact" className={className} onClick={onNavigate}>
{CTA_LABEL}
</Link>
);
}
export function Header({
whatsAppContexts,
}: {
/** Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx), para o WhatsApp falar da página aberta. */
whatsAppContexts?: WhatsAppContexts;
}) {
const pathname = usePathname();
const viaWhatsApp = isServiceOrCasePath(pathname);
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null); const menuButtonRef = useRef<HTMLButtonElement>(null);
@@ -45,13 +98,13 @@ export function Header() {
}`} }`}
> >
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6"> <div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
<a <HoverPrefetchLink
href="/#home" href="/#home"
aria-label="Evandro.dev.br — início" aria-label="Evandro.dev.br — início"
className="flex min-h-11 shrink-0 items-center no-underline" className="flex min-h-11 shrink-0 items-center no-underline"
> >
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" /> <Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
</a> </HoverPrefetchLink>
<nav <nav
aria-label="Navegação principal" aria-label="Navegação principal"
@@ -60,12 +113,12 @@ export function Header() {
<ul className="flex list-none items-center gap-sp-4"> <ul className="flex list-none items-center gap-sp-4">
{NAVIGATION_ITEMS.map((item) => ( {NAVIGATION_ITEMS.map((item) => (
<li key={item.href}> <li key={item.href}>
<a <HoverPrefetchLink
href={item.href} href={item.href}
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white" className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
> >
{item.label} {item.label}
</a> </HoverPrefetchLink>
</li> </li>
))} ))}
</ul> </ul>
@@ -73,12 +126,12 @@ export function Header() {
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
<div className="hidden xl:block"> <div className="hidden xl:block">
<a <ContactCta
href="/#contact"
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]" className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
> placement="header"
Falar sobre meu projeto viaWhatsApp={viaWhatsApp}
</a> subject={subject}
/>
</div> </div>
<button <button
ref={menuButtonRef} ref={menuButtonRef}
@@ -104,41 +157,37 @@ export function Header() {
</div> </div>
{mobileMenuOpen ? ( {mobileMenuOpen ? (
<div className="absolute inset-x-0 top-[var(--site-header-height)] max-h-[calc(100dvh-var(--site-header-height))] overflow-y-auto border-b border-white/12 bg-[var(--ink)] px-sp-6 lg:px-sp-8 xl:hidden"> // Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
<div className="ds-section-shell flex flex-col py-sp-6"> <div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom))] lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-1 flex-col">
<nav <nav
id={MENU_ID} id={MENU_ID}
aria-hidden={!mobileMenuOpen}
aria-label="Navegação mobile" aria-label="Navegação mobile"
className="flex flex-1 flex-col"
onKeyDown={handleMenuKeyDown} onKeyDown={handleMenuKeyDown}
> >
<ul className="flex list-none flex-col gap-sp-2"> <ul className="flex list-none flex-col">
{NAVIGATION_ITEMS.map((item) => ( {NAVIGATION_ITEMS.map((item) => (
<li key={item.href}> <li key={item.href} className="border-b border-white/10">
<MagneticButton <HoverPrefetchLink
as="a"
strength={0}
wrapperClassName="w-full"
href={item.href} href={item.href}
className="flex min-h-11 items-center rounded-sm px-sp-3 py-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white" className="flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline transition-colors hover:text-white"
onClick={closeMobileMenu} onClick={closeMobileMenu}
> >
{item.label} {item.label}
</MagneticButton> </HoverPrefetchLink>
</li> </li>
))} ))}
<li>
<MagneticButton
as="a"
strength={0}
href="/#contact"
className="mt-sp-4 inline-flex min-h-11 items-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
onClick={closeMobileMenu}
>
Falar sobre meu projeto
</MagneticButton>
</li>
</ul> </ul>
<div className="mt-auto pt-sp-8">
<ContactCta
className="flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
placement="header_menu"
viaWhatsApp={viaWhatsApp}
subject={subject}
onNavigate={closeMobileMenu}
/>
</div>
</nav> </nav>
</div> </div>
</div> </div>
+21 -11
View File
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import Hero from "./Hero"; import Hero from "./Hero";
/** /**
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): statItems() (sem stats, sem anos, zero anos, com anos), * Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (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. * links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
*/ */
@@ -23,27 +23,37 @@ afterEach(() => {
delete window.gtag; delete window.gtag;
}); });
describe("Hero: números de experiência (statItems)", () => { describe("Hero: anos de experiência (na linha de clientes)", () => {
it.each([ it.each([
["no stats at all", undefined], ["no stats at all", undefined],
["stats without years", {}], ["stats without years", {}],
["zero years", { years: 0 }], ["zero years", { years: 0 }],
])("path: %s -> no figures block", (_label, stats) => { ])("path: %s -> no experience text", (_label, stats) => {
const { container } = render(<Hero stats={stats} />); const { container } = render(<Hero stats={stats} />);
expect(container.querySelector("dl")).toBeNull(); expect(container.querySelector("dl")).toBeNull();
expect(screen.queryByText("anos de experiência")).toBeNull(); expect(screen.queryByText("anos de experiência")).toBeNull();
}); });
it("path: years given -> one figure with the number and its label", () => { it("path: years given -> the number and its label sit in the same line as the clients", () => {
const { container } = render(<Hero stats={{ years: 7 }} />); const { container } = render(
<Hero
const items = container.querySelectorAll("dl > div"); stats={{ years: 7 }}
expect(items).toHaveLength(1); clients={[{ id: "lavacao-047", name: "Lavação 047" }]}
expect(items[0].querySelector("dd")).toHaveTextContent("7"); />,
expect(items[0].querySelector("dt")).toHaveTextContent(
"anos de experiência",
); );
expect(container.querySelector("dl")).toBeNull();
const strip = screen.getByText("Já entregue para:").parentElement;
expect(strip).toHaveTextContent("Lavação 047");
expect(strip).toHaveTextContent("7 anos de experiência");
});
it("path: years given but no clients -> only the experience text, with no 'Já entregue para'", () => {
render(<Hero stats={{ years: 7 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(screen.queryByText("Já entregue para:")).toBeNull();
}); });
}); });
@@ -1,14 +1,8 @@
import { act } from "@testing-library/react"; import { act } from "@testing-library/react";
import { useReducedMotion } from "motion/react";
import { hydrateRoot, type Root } from "react-dom/client"; import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server"; import { renderToString } from "react-dom/server";
import Hero from "./Hero"; import Hero from "./Hero";
jest.mock("motion/react", () => ({
...jest.requireActual("motion/react"),
useReducedMotion: jest.fn(),
}));
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
@@ -26,13 +20,10 @@ it.each([false, true])(
removeEventListener: jest.fn(), removeEventListener: jest.fn(),
dispatchEvent: jest.fn(), dispatchEvent: jest.fn(),
})); }));
const preference = jest.mocked(useReducedMotion);
preference.mockReturnValue(null);
const container = document.createElement("div"); const container = document.createElement("div");
container.innerHTML = renderToString(<Hero />); container.innerHTML = renderToString(<Hero />);
document.body.append(container); document.body.append(container);
preference.mockReturnValue(reducedMotion);
const consoleErrors = jest.spyOn(console, "error").mockImplementation(); const consoleErrors = jest.spyOn(console, "error").mockImplementation();
const recoverableErrors: unknown[] = []; const recoverableErrors: unknown[] = [];
let root: Root | undefined; let root: Root | undefined;
+18 -28
View File
@@ -1,5 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero from "./Hero"; import Hero from "./Hero";
@@ -10,30 +9,6 @@ const clients = [
{ id: "as-servicos", name: "AS Serviços" }, { id: "as-servicos", name: "AS Serviços" },
]; ];
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
initial: _initial,
animate: _animate,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion, useReducedMotion: () => false };
});
describe("Hero", () => { describe("Hero", () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(window, "open").mockImplementation(() => null); jest.spyOn(window, "open").mockImplementation(() => null);
@@ -63,10 +38,21 @@ describe("Hero", () => {
render(<Hero />); render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sistemas sob medida para operações que não cabem em soluções prontas.", "Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.",
); );
}); });
it("highlights only the short stretch 'pedidos pelo WhatsApp' in lime", () => {
render(<Hero />);
const highlighted = Array.from(
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
)
.filter((word) => word.className.includes("text-[var(--slash)]"))
.map((word) => word.textContent);
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
});
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => { it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
render(<Hero />); render(<Hero />);
@@ -92,7 +78,7 @@ describe("Hero", () => {
expect( expect(
screen.getByText( 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.", "Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala direto com quem desenvolve.",
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("link", { name: /conversar/i })).toHaveAttribute( expect(screen.getByRole("link", { name: /conversar/i })).toHaveAttribute(
@@ -106,6 +92,7 @@ describe("Hero", () => {
expect(screen.getByText("anos de experiência")).toBeInTheDocument(); expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument(); expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.queryByRole("term")).toBeNull();
expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull(); expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull();
expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull(); expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull();
}); });
@@ -134,7 +121,10 @@ describe("Hero", () => {
} }
expect(images[0]).toHaveAttribute( expect(images[0]).toHaveAttribute(
"src", "src",
"/videos/lavacao-047/desktop.webp", "/videos/sev-modaintima/desktop.webp",
);
expect(images[0]).toHaveAccessibleName(
"Site da SEV Moda Íntima aberto no computador",
); );
expect(images[1]).toHaveAttribute("src", "/videos/as-servicos/mobile.webp"); expect(images[1]).toHaveAttribute("src", "/videos/as-servicos/mobile.webp");
}); });
+24 -43
View File
@@ -23,28 +23,25 @@ const SOCIAL_LINKS = [
]; ];
/** /**
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json. * Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json
* e aparecem na mesma linha de "Já entregue para" (ClientStrip).
*/ */
export type HeroStats = { export type HeroStats = {
years?: number; years?: number;
}; };
type StatItem = { /** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
value: string; const ACCENT = new Set([5, 6, 7]);
label: string;
};
function statItems(stats?: HeroStats): StatItem[] {
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. */ /** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
const TITLE = const TITLE =
"Sistemas sob medida para operações que não cabem em soluções prontas.".split( "Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha."
" ", .split(" ")
); .map((word, index) => ({
const ACCENT = new Set([1, 2]); word,
// a mesma palavra se repete ("e"), então a posição entra na chave
key: `${word}-${index}`,
accent: ACCENT.has(index),
}));
/** O que cada camada da cena significa, em linguagem simples. */ /** O que cada camada da cena significa, em linguagem simples. */
const CAPTIONS = [ const CAPTIONS = [
@@ -63,11 +60,9 @@ export default function Hero({
}: { }: {
stats?: HeroStats; stats?: HeroStats;
clients?: ClientLink[]; clients?: ClientLink[];
/** Endereço mostrado na barra do navegador de HeroWork (o do site da Lavação 047). */ /** Endereço mostrado na barra do navegador de HeroWork (o domínio próprio do site da SEV Moda Íntima). */
workUrl?: HeroWorkUrl; workUrl?: HeroWorkUrl;
}) { }) {
const items = statItems(stats);
return ( return (
<section <section
id="home" id="home"
@@ -94,12 +89,12 @@ export default function Hero({
Evandro Fonseca Junior · Engenharia de software Evandro Fonseca Junior · Engenharia de software
</p> </p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */} {/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
{/* No desktop o título para antes da coluna HeroWork (largura da coluna: clamp(300px,24vw,400px)). */} {/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
<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]"> <h1 className="hero-title max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em] lg:max-w-[54%]">
{TITLE.map((word, index) => ( {TITLE.map(({ word, key, accent }, index) => (
<Fragment key={word}> <Fragment key={key}>
<span <span
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`} className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties} style={{ "--i": index } as CSSProperties}
> >
{word} {word}
@@ -107,10 +102,9 @@ export default function Hero({
</Fragment> </Fragment>
))} ))}
</h1> </h1>
<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))]"> <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(36em,54%)]">
Para empresas que cresceram além das planilhas e dos sistemas Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
prontos: sites, sistemas web, aplicativos e integrações, com contato fala direto com quem desenvolve.
direto com quem desenvolve.
</p> </p>
<div className="hero-in mt-sp-5 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">
@@ -141,7 +135,8 @@ export default function Hero({
<ClientStrip <ClientStrip
clients={clients} clients={clients}
className="hero-in mt-sp-4 [--d:.75s] lg:max-w-[min(44rem,calc(100vw-clamp(300px,24vw,400px)-9rem))]" years={stats?.years}
className="hero-in mt-sp-3 [--d:.75s]"
/> />
<HeroWork url={workUrl} /> <HeroWork url={workUrl} />
@@ -149,24 +144,10 @@ export default function Hero({
<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="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"> <div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(({ value, label }) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{value}
</dd>
</div>
))}
</dl>
)}
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */} {/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden"> <div className="max-lg:hidden">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]"> <ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sistemas web", "Aplicativos", "Integrações"].map( {["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
(service) => ( (service) => (
<li key={service} className="flex items-center gap-sp-2"> <li key={service} className="flex items-center gap-sp-2">
<span <span
+12 -8
View File
@@ -1,18 +1,18 @@
import { getShowcaseAssets } from "@/lib/project-showcase"; import { getShowcaseAssets } from "@/lib/project-showcase";
import { BrowserFrame } from "./showcase/BrowserFrame"; import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame"; import { PhoneFrame } from "./showcase/PhoneFrame";
import "./showcase/showcase.css";
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */ /** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
export type HeroWorkUrl = { text: string; secure: boolean } | null; export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "lavacao-047"; const DESKTOP_PROJECT = "sev-modaintima";
const MOBILE_PROJECT = "as-servicos"; 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 * Trabalho real ao lado do título, só em telas de 1024 px ou mais: o pôster do site da SEV Moda Íntima no navegador
* e o do aplicativo da AS Serviços no celular. A coluna inteira fica escondida (display: none) abaixo disso, * (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo da AS Serviços no celular.
* 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. * A coluna ocupa cerca de 40% da largura. Ela fica escondida (display: none) abaixo de 1024 px,
* então as imagens (lazy) nem são baixadas no celular.
*/ */
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) { export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop"); const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
@@ -21,13 +21,13 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
return ( return (
<div <div
data-testid="hero-work" 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" className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
> >
<BrowserFrame url={url} className="w-[88%]"> <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 */} {/* 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 <img
src={desktop.poster} src={desktop.poster}
alt="Site da Lavação 047 aberto no computador" alt="Site da SEV Moda Íntima aberto no computador"
width={desktop.width} width={desktop.width}
height={desktop.height} height={desktop.height}
loading="lazy" loading="lazy"
@@ -36,7 +36,11 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
className="block h-auto w-full" className="block h-auto w-full"
/> />
</BrowserFrame> </BrowserFrame>
<PhoneFrame className="absolute right-0 bottom-[-12%] w-[34%]"> {/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
<PhoneFrame
className="right-0 bottom-[-12%] w-[26%]"
style={{ position: "absolute" }}
>
{/* biome-ignore lint/performance/noImgElement: mesmo motivo do pôster desktop */} {/* biome-ignore lint/performance/noImgElement: mesmo motivo do pôster desktop */}
<img <img
src={phone.poster} src={phone.poster}
+95
View File
@@ -0,0 +1,95 @@
import { render, screen, within } from "@testing-library/react";
import HomeFaq, { FAQ_ITEMS } from "./HomeFaq";
describe("HomeFaq", () => {
it("shows the four questions the site already backs up, closed by default", () => {
const { container } = render(<HomeFaq />);
expect(
screen.getByRole("heading", { name: "Perguntas frequentes" }),
).toBeInTheDocument();
const summaries = Array.from(container.querySelectorAll("summary")).map(
(summary) => summary.textContent?.replace("+", "").trim(),
);
expect(summaries).toEqual([
"Como começa um projeto?",
"Você atende fora de Santa Catarina?",
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
"O que preciso ter para começar?",
]);
for (const details of container.querySelectorAll("details")) {
expect(details.open).toBe(false);
}
});
it("answers from what the site already says: first conversation, remote in all of Brazil, and the AI review", () => {
render(<HomeFaq />);
expect(
screen.getByText(/Com uma conversa para entender o problema/),
).toBeInTheDocument();
expect(
screen.getByText(
/o atendimento é remoto, para empresas de todo o Brasil/,
),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver a revisão de app feito com IA" }),
).toHaveAttribute("href", "/ia");
});
it("does not render the questions the owner has not answered yet", () => {
const { container } = render(<HomeFaq />);
for (const unanswered of [
"O código e os acessos ficam comigo?",
"Em nome de quem ficam domínio e hospedagem?",
"Como funciona o pagamento?",
]) {
// preparadas na lista, com resposta vazia...
expect(FAQ_ITEMS.find((item) => item.question === unanswered)).toEqual({
question: unanswered,
answer: "",
});
// ...e fora da página
expect(screen.queryByText(unanswered)).toBeNull();
}
expect(container.querySelectorAll("details")).toHaveLength(4);
});
it("starts showing a prepared question as soon as it gets an answer", () => {
render(
<HomeFaq
items={[
{
question: "Como funciona o pagamento?",
answer: "Resposta do dono.",
},
{ question: "Ainda sem resposta?", answer: " " },
]}
/>,
);
const details = screen
.getByText("Como funciona o pagamento?")
.closest("details") as HTMLElement;
expect(within(details).getByText("Resposta do dono.")).toBeInTheDocument();
expect(screen.queryByText("Ainda sem resposta?")).toBeNull();
});
it("renders nothing, not even the heading, when no question has an answer", () => {
const { container } = render(
<HomeFaq items={[{ question: "Pergunta?", answer: "" }]} />,
);
expect(container).toBeEmptyDOMElement();
});
it("states no price, deadline, guarantee or payment policy", () => {
const { container } = render(<HomeFaq />);
expect(container.textContent).not.toMatch(
/R\$|garantia|\d+ dias|em até \d+|parcel|entrada de/i,
);
});
});
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { textLink } from "./button-styles";
export interface FaqItem {
question: string;
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
answer: string;
link?: { href: string; label: string };
}
/**
* Perguntas da home. As quatro primeiras só dizem o que o site já sustenta (contato, rodapé, /ia).
* As três últimas estão preparadas, sem resposta: o dono as responde depois e elas passam a aparecer.
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que ele não tenha dito.
*/
export const FAQ_ITEMS: FaqItem[] = [
{
question: "Como começa um projeto?",
answer:
"Com uma conversa para entender o problema, sem precisar chegar com a solução definida. Depois dela, você recebe o escopo e a proposta por escrito.",
},
{
question: "Você atende fora de Santa Catarina?",
answer:
"Sim. Minha base é em Garuva, SC, e o atendimento é remoto, para empresas de todo o Brasil.",
},
{
question:
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
answer:
"Sim. Antes de propor qualquer coisa, avalio o que pode ser aproveitado, quais riscos precisam de correção e o que falta para atender usuários em produção. Nem todo problema exige começar de novo.",
link: { href: "/ia", label: "Ver a revisão de app feito com IA" },
},
{
question: "O que preciso ter para começar?",
answer:
"Basta contar o que você quer resolver e, se houver, o prazo que tem em mente. Se já existe um sistema, uma planilha ou um protótipo, mostrar ajuda, mas não é obrigatório.",
},
{ question: "O código e os acessos ficam comigo?", answer: "" },
{ question: "Em nome de quem ficam domínio e hospedagem?", answer: "" },
{ question: "Como funciona o pagamento?", answer: "" },
];
/** Perguntas frequentes da home, antes do contato. Só aparecem as que têm resposta; sem nenhuma, a seção não existe. */
export default function HomeFaq({ items = FAQ_ITEMS }: { items?: FaqItem[] }) {
const answered = items.filter((item) => item.answer.trim() !== "");
if (answered.length === 0) return null;
return (
<section id="faq" aria-labelledby="faq-title" className="ds-section">
<div className="ds-section-shell">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2
id="faq-title"
className="ds-section-title max-w-[22ch] text-balance"
>
Perguntas frequentes
</h2>
<div className="mt-sp-8 max-w-[860px]">
{answered.map((item) => (
<details
key={item.question}
className="group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
<span
aria-hidden="true"
className="shrink-0 font-mono text-xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</summary>
<div className="pb-sp-5">
<p className="ds-copy max-w-[65ch]">{item.answer}</p>
{item.link ? (
<Link
href={item.link.href}
className={`${textLink} mt-sp-2 text-[var(--ink-80)]`}
>
{item.link.label}
</Link>
) : null}
</div>
</details>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from "@testing-library/react";
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Testes de caminhos-base de HoverPrefetchLink (complemento de HoverPrefetchLink.test.tsx):
* o aviso do toque ao dono do link, o pré-carregamento que liga uma vez só e não desliga,
* e as demais propriedades do link, que passam inalteradas.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const link = () => screen.getByRole("link", { name: "Cases" });
describe("HoverPrefetchLink", () => {
it("path: touch with the caller's handler -> the handler runs once, with the event, and prefetching starts", () => {
const onTouchStart = jest.fn();
render(
<HoverPrefetchLink href="/cases" onTouchStart={onTouchStart}>
Cases
</HoverPrefetchLink>,
);
fireEvent.touchStart(link());
expect(onTouchStart).toHaveBeenCalledTimes(1);
expect(onTouchStart.mock.calls[0][0]).toMatchObject({ type: "touchstart" });
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("path: hover, then focus, then touch -> prefetching stays on and each handler runs every time", () => {
const onMouseEnter = jest.fn();
const onFocus = jest.fn();
const onTouchStart = jest.fn();
render(
<HoverPrefetchLink
href="/cases"
onMouseEnter={onMouseEnter}
onFocus={onFocus}
onTouchStart={onTouchStart}
>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
expect(onMouseEnter).toHaveBeenCalledTimes(2);
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onTouchStart).toHaveBeenCalledTimes(1);
});
it("path: leaving the link after reaching it -> prefetching is not switched off again", () => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
fireEvent.mouseLeave(link());
fireEvent.blur(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("path: events the link does not watch (click, key press) -> prefetching stays off", () => {
const onClick = jest.fn();
render(
<HoverPrefetchLink href="/cases" onClick={onClick}>
Cases
</HoverPrefetchLink>,
);
fireEvent.click(link());
fireEvent.keyDown(link(), { key: "Enter" });
expect(onClick).toHaveBeenCalledTimes(1);
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("path: other props -> reach the link as they were given", () => {
render(
<HoverPrefetchLink
href="/#home"
aria-label="Evandro.dev.br — início"
className="flex min-h-11"
>
Cases
</HoverPrefetchLink>,
);
const home = screen.getByRole("link", { name: "Evandro.dev.br — início" });
expect(home).toHaveAttribute("href", "/#home");
expect(home).toHaveClass("flex", "min-h-11");
});
});
+81
View File
@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from "@testing-library/react";
import HoverPrefetchLink from "./HoverPrefetchLink";
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const link = () => screen.getByRole("link", { name: "Cases" });
it("does not prefetch when the page opens", () => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
expect(link()).toHaveAttribute("href", "/cases");
});
it.each([
["mouse", (element: Element) => fireEvent.mouseEnter(element)],
["keyboard focus", (element: Element) => fireEvent.focus(element)],
["touch", (element: Element) => fireEvent.touchStart(element)],
])("starts prefetching when the visitor reaches it by %s", (_label, reach) => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
reach(link());
// null = o comportamento padrão do Link (pré-carrega a rota)
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("keeps the callers' own handlers", () => {
const onMouseEnter = jest.fn();
const onFocus = jest.fn();
const onClick = jest.fn();
render(
<HoverPrefetchLink
href="/cases"
onMouseEnter={onMouseEnter}
onFocus={onFocus}
onClick={onClick}
>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.click(link());
expect(onMouseEnter).toHaveBeenCalledTimes(1);
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("only prefetches the link that was reached, not its siblings", () => {
render(
<>
<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>
<HoverPrefetchLink href="/servicos">Serviços</HoverPrefetchLink>
</>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
expect(screen.getByRole("link", { name: "Serviços" })).toHaveAttribute(
"data-prefetch",
"false",
);
});
+38
View File
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { type ComponentProps, useState } from "react";
/**
* Link interno que só pré-carrega a rota quando a pessoa chega nele (mouse, teclado ou toque).
* O padrão do Link pré-carrega todo link visível ao abrir a página, e o cabeçalho e a faixa de clientes
* somavam 15 pedidos disputando a rede na abertura. Com `prefetch={false}` o App Router não pré-carrega nem no hover.
* Padrão da documentação do Next: node_modules/next/dist/docs/01-app/02-guides/prefetching.md.
*/
export default function HoverPrefetchLink({
onMouseEnter,
onFocus,
onTouchStart,
...props
}: Omit<ComponentProps<typeof Link>, "prefetch">) {
const [active, setActive] = useState(false);
return (
<Link
{...props}
prefetch={active ? null : false}
onMouseEnter={(event) => {
setActive(true);
onMouseEnter?.(event);
}}
onFocus={(event) => {
setActive(true);
onFocus?.(event);
}}
onTouchStart={(event) => {
setActive(true);
onTouchStart?.(event);
}}
/>
);
}
@@ -0,0 +1,79 @@
import { fireEvent, render, screen } from "@testing-library/react";
import WhatsAppLink from "./WhatsAppLink";
/**
* Testes de caminhos-base do onClick de WhatsAppLink (complemento de WhatsAppLink.paths.test.tsx):
* o aviso ao dono do link (por exemplo, para fechar o menu do celular) corre junto com a abertura,
* qualquer que seja o botão do clique, e não corre no auxclick. Sem onClick, o clique funciona do mesmo jeito.
*/
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(onClick?: () => void) {
render(
<WhatsAppLink placement="header_menu" onClick={onClick}>
Conversar
</WhatsAppLink>,
);
return screen.getByRole("link", { name: "Conversar" });
}
describe("WhatsAppLink: onClick", () => {
it("path: primary click with onClick -> the callback runs once and the handoff is measured", () => {
const onClick = jest.fn();
const link = renderLink(onClick);
fireEvent.click(link, { button: 0 });
expect(onClick).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ placement: "header_menu" }),
);
});
it("path: click event from the middle button (some browsers send one; React drops the secondary button) and onClick -> the callback still runs, but nothing is measured", () => {
const onClick = jest.fn();
const link = renderLink(onClick);
fireEvent.click(link, { button: 1 });
expect(onClick).toHaveBeenCalledTimes(1);
expect(window.gtag).not.toHaveBeenCalled();
});
it("path: no onClick -> the primary click is measured and nothing breaks", () => {
const link = renderLink();
expect(() => fireEvent.click(link, { button: 0 })).not.toThrow();
expect(window.gtag).toHaveBeenCalledTimes(1);
});
it("path: middle-button auxclick with onClick -> measured, but the callback does not run", () => {
const onClick = jest.fn();
const link = renderLink(onClick);
link.dispatchEvent(
new MouseEvent("auxclick", {
button: 1,
bubbles: true,
cancelable: true,
}),
);
expect(onClick).not.toHaveBeenCalled();
expect(window.gtag).toHaveBeenCalledTimes(1);
});
});
+19
View File
@@ -54,6 +54,25 @@ it("talks about 'um projeto' when no service is given", () => {
expect(text).not.toContain("Contato."); expect(text).not.toContain("Contato.");
}); });
it.each(["", " "])(
"talks about 'um projeto' when the service is blank (%j), never 'sobre .'",
(service) => {
render(
<WhatsAppLink service={service} placement="contact_direct">
Conversar
</WhatsAppLink>,
);
const url = new URL(
screen.getByRole("link", { name: "Conversar" }).getAttribute("href") ||
"",
);
expect(url.searchParams.get("text")).toBe(
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.",
);
},
);
it.each([null, "denied", "{malformed", '"granted"'])( it.each([null, "denied", "{malformed", '"granted"'])(
"keeps a native destination without collecting or replaying when consent is %s", "keeps a native destination without collecting or replaying when consent is %s",
async (consent) => { async (consent) => {
+8 -2
View File
@@ -10,6 +10,8 @@ interface WhatsAppLinkProps {
/** Assunto da conversa, na frase "Quero conversar sobre {service}". Sem ele, a mensagem fala de "um projeto". */ /** Assunto da conversa, na frase "Quero conversar sobre {service}". Sem ele, a mensagem fala de "um projeto". */
service?: string; service?: string;
placement: string; placement: string;
/** Chamado junto com a abertura do link, por exemplo para fechar o menu do celular. */
onClick?: () => void;
} }
/** Rótulo de métrica quando o link não traz um assunto; o texto da mensagem usa "um projeto". */ /** Rótulo de métrica quando o link não traz um assunto; o texto da mensagem usa "um projeto". */
@@ -20,12 +22,15 @@ export default function WhatsAppLink({
className = "", className = "",
service, service,
placement, placement,
onClick,
}: WhatsAppLinkProps) { }: WhatsAppLinkProps) {
const analyticsService = service ?? DEFAULT_ANALYTICS_SERVICE; // assunto vazio ou só com espaços vale como ausente: sem isso a mensagem sairia "sobre ."
const subject = service?.trim() || undefined;
const analyticsService = subject ?? DEFAULT_ANALYTICS_SERVICE;
return ( return (
<a <a
href={buildWhatsAppUrl( href={buildWhatsAppUrl(
`Olá, Evandro! Quero conversar sobre ${service ?? "um projeto"}. Contato iniciado pelo site evandro.dev.br.`, `Olá, Evandro! Quero conversar sobre ${subject ?? "um projeto"}. Contato iniciado pelo site evandro.dev.br.`,
)} )}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -33,6 +38,7 @@ export default function WhatsAppLink({
onClick={(event) => { onClick={(event) => {
if (event.button === 0) if (event.button === 0)
trackWhatsAppOpen({ service: analyticsService, placement }); trackWhatsAppOpen({ service: analyticsService, placement });
onClick?.();
}} }}
onAuxClick={(event) => { onAuxClick={(event) => {
if (event.button === 1) if (event.button === 1)
+125 -117
View File
@@ -1,54 +1,14 @@
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { primaryButton, secondaryButtonLight } from "./button-styles"; import { primaryButton, secondaryButtonLight } from "./button-styles";
import { WorkModalities } from "./WorkModalities"; import { WorkModalities } from "./WorkModalities";
/** /**
* Testes de caminhos-base de WorkModalities e TierCard: movimento normal x reduzido, plano em destaque * Testes de caminhos-base de WorkModalities e TierCard: a entrada dos planos (Reveal, CSS), o toque e o passe do
* (botão e borda) x demais planos, e serviços do hub ausentes x presentes. * mouse (classes cv-press e cv-lift, sem foco nos cartões), plano em destaque (botão e borda) x demais planos,
* O `motion/react` é trocado por elementos simples que registram as props de animação. * primeiro passo de cada plano, e serviços do hub (só os três principais).
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
*/ */
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", () => ({ jest.mock("./Section", () => ({
Section: ({ children, id }: { children?: React.ReactNode; id?: string }) => ( Section: ({ children, id }: { children?: React.ReactNode; id?: string }) => (
<section id={id}>{children}</section> <section id={id}>{children}</section>
@@ -57,94 +17,91 @@ jest.mock("./Section", () => ({
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null })); jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
type Seen = { /** O hub inteiro, fora da ordem da home: a home mostra só sites, sistemas web e integrações, nesta ordem. */
variants?: {
hidden: Record<string, unknown>;
visible: { transition: Record<string, unknown> };
};
whileHover?: unknown;
whileTap?: unknown;
};
const serviceLinks = [ const serviceLinks = [
{
href: "/servicos/tauri-rust",
label: "Aplicativos de desktop",
description: "Programas rápidos para o computador.",
},
{
href: "/servicos/integracao-hardware-software-iot",
label: "Integração de equipamentos e sistemas",
description: "Máquinas e sistemas trocando dados.",
},
{
href: "/servicos/consultoria-nextjs",
label: "Melhorias em sites e sistemas",
description: "Evolução do que já existe.",
},
{ {
href: "/servicos/criacao-de-sites", href: "/servicos/criacao-de-sites",
label: "Sites profissionais", label: "Sites profissionais",
description: "Presença digital para a empresa.", description: "Presença digital para a empresa.",
}, },
{ {
href: "/servicos/tauri-rust", href: "/servicos/engenharia-de-software",
label: "Aplicativos de desktop", label: "Sistemas web e aplicativos mobile",
description: "Programas rápidos para o computador.", description: "Sistemas sob medida.",
},
{
href: "/servicos/arquitetura-de-software",
label: "Diagnóstico e arquitetura",
description: "Avaliação técnica.",
}, },
]; ];
function seen(tag: string): Seen[] {
return mockSeen.mock.calls
.filter(([name]) => name === tag)
.map(([, props]) => props as Seen);
}
beforeEach(() => {
mockSeen.mockClear();
});
describe("WorkModalities: movimento", () => { describe("WorkModalities: movimento", () => {
it("path: normal motion -> tiers rise in, with hover lift and press feedback", () => { it("path: tiers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => {
jest.mocked(useReducedMotion).mockReturnValue(false); const { container } = render(<WorkModalities />);
render(<WorkModalities />); const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
expect(reveals).toHaveLength(3);
const items = seen("div").filter( expect(
(props) => props.variants?.hidden?.opacity === 0, reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
); ).toEqual(["20px", "20px", "20px"]);
expect(items).toHaveLength(3); expect(
for (const item of items) { reveals.map((element) =>
expect(item.variants?.hidden).toEqual({ element.style.getPropertyValue("--reveal-duration"),
opacity: 0, ),
y: 20, ).toEqual(["0.75s", "0.75s", "0.75s"]);
clipPath: "inset(100% 0 0 0)", expect(
}); reveals.map((element) =>
expect(item.variants?.visible.transition).toEqual({ Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
duration: 0.75, ),
ease: [0.16, 1, 0.3, 1], ).toEqual([0.08, 0.16, 0.24]);
}); // o estado escondido vive no CSS: sem opacidade nem transformação inline
for (const element of reveals) {
expect(element.style.opacity).toBe("");
expect(element.style.transform).toBe("");
} }
const cards = seen("article"); });
it("path: the cards are not focus stops and keep no outline-none (touch and hover feedback come from CSS)", () => {
const { container } = render(<WorkModalities />);
const cards = container.querySelectorAll("article");
expect(cards).toHaveLength(3); expect(cards).toHaveLength(3);
for (const card of cards) { for (const card of cards) {
expect(card.whileHover).toEqual({ expect(card).not.toHaveAttribute("tabindex");
y: -4, expect(card.className).not.toContain("outline-none");
boxShadow: "0 24px 50px rgba(0, 0, 0, 0.08)", expect(card).toHaveClass("cv-press", "cv-lift");
});
expect(card.whileTap).toEqual({ scale: 0.99 });
} }
}); });
it("path: reduced motion -> tiers only fade, with no hover lift and no press feedback", () => { it("path: each tier sits alone in its own reveal wrapper, which can shrink in the grid", () => {
jest.mocked(useReducedMotion).mockReturnValue(true); const { container } = render(<WorkModalities />);
render(<WorkModalities />); for (const card of container.querySelectorAll("article")) {
const wrapper = card.closest(".ds-reveal");
const items = seen("div").filter( expect(wrapper).toHaveClass("min-w-0");
(props) => props.variants?.hidden?.opacity === 0, expect(wrapper?.querySelectorAll("article")).toHaveLength(1);
);
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", () => { describe("WorkModalities: plano em destaque", () => {
it("path: the featured tier has the filled button and the thick border; the others have outline buttons", () => { 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 { container } = render(<WorkModalities />);
const featured = screen.getByRole("link", { const featured = screen.getByRole("link", {
@@ -167,21 +124,17 @@ describe("WorkModalities: plano em destaque", () => {
describe("WorkModalities: serviços do hub", () => { describe("WorkModalities: serviços do hub", () => {
it("path: no serviceLinks prop -> no service cards, the 'all services' link stays", () => { it("path: no serviceLinks prop -> no service cards, the 'all services' link stays", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
render(<WorkModalities />); render(<WorkModalities />);
expect( expect(
screen.getByRole("link", { name: "Conhecer todos os serviços" }), screen.getByRole("link", { name: "Ver todos os serviços" }),
).toHaveAttribute("href", "/servicos"); ).toHaveAttribute("href", "/servicos");
expect( expect(
screen.queryByRole("link", { name: /Sites profissionais/ }), screen.queryByRole("link", { name: /Sites profissionais/ }),
).toBeNull(); ).toBeNull();
}); });
it("path: serviceLinks given -> one card per service, in order, linking to its page", () => { it("path: serviceLinks given -> only the three main services (sites, sistemas web, integrações), in that order", () => {
jest.mocked(useReducedMotion).mockReturnValue(false);
const { container } = render( const { container } = render(
<WorkModalities serviceLinks={serviceLinks} />, <WorkModalities serviceLinks={serviceLinks} />,
); );
@@ -190,14 +143,69 @@ describe("WorkModalities: serviços do hub", () => {
"[data-reel-stagger='left']", "[data-reel-stagger='left']",
) as HTMLElement; ) as HTMLElement;
const links = within(grid).getAllByRole("link"); const links = within(grid).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual( expect(links.map((link) => link.getAttribute("href"))).toEqual([
serviceLinks.map(({ href }) => href), "/servicos/criacao-de-sites",
); "/servicos/engenharia-de-software",
"/servicos/integracao-hardware-software-iot",
]);
expect( expect(
within(links[0]).getByText("Sites profissionais"), within(links[0]).getByText("Sites profissionais"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
within(links[1]).getByText("Programas rápidos para o computador."), within(links[1]).getByText("Sistemas sob medida."),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver todos os serviços" }),
).toHaveAttribute("href", "/servicos");
});
it("path: a main service missing from the hub -> it is skipped, the others stay", () => {
const { container } = render(
<WorkModalities
serviceLinks={serviceLinks.filter(
({ href }) => !href.endsWith("/engenharia-de-software"),
)}
/>,
);
const grid = container.querySelector(
"[data-reel-stagger='left']",
) as HTMLElement;
expect(
within(grid)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/servicos/criacao-de-sites",
"/servicos/integracao-hardware-software-iot",
]);
});
});
describe("WorkModalities: primeiro passo", () => {
it("path: each tier states a concrete first step, with no price, deadline or contract promise", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
expect(articles).toHaveLength(3);
for (const article of articles) {
expect(within(article).getByText("Primeiro passo")).toBeInTheDocument();
}
expect(
screen.getAllByText(
/Primeira conversa para entender o problema\. Depois, escopo e proposta por escrito\./,
),
).toHaveLength(1);
expect(
screen.getByText(/Você me mostra o que já existe\./),
).toBeInTheDocument();
for (const gone of [
"Proposta por projeto",
"Escopo de análise definido",
"Etapas acordadas",
]) {
expect(screen.queryByText(gone)).toBeNull();
}
expect(container.textContent).not.toMatch(/R\$|em contrato|prazo de \d/);
}); });
}); });
+44 -73
View File
@@ -1,11 +1,9 @@
"use client";
import { motion, type Variants } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { ServiceLink } from "@/lib/site-navigation"; import type { ServiceLink } from "@/lib/site-navigation";
import { DrawLine } from "./animations/DrawLine"; import { DrawLine } from "./animations/DrawLine";
import { primaryButton, secondaryButtonLight } from "./button-styles"; import { primaryButton, secondaryButtonLight } from "./button-styles";
import "./conversion.css";
import { Reveal } from "./Reveal";
import { Section } from "./Section"; import { Section } from "./Section";
interface Tier { interface Tier {
@@ -15,8 +13,8 @@ interface Tier {
tagline: string; tagline: string;
description: string; description: string;
deliverables: string[]; deliverables: string[];
pricing: string; /** Primeiro passo concreto, sem prazo nem preço. */
pricingNote: string; firstStep: string;
featured?: boolean; featured?: boolean;
ctaLabel: string; ctaLabel: string;
ctaHref: string; ctaHref: string;
@@ -36,8 +34,8 @@ const TIERS: Tier[] = [
"Integrações e permissões de acesso", "Integrações e permissões de acesso",
"Validação, publicação e documentação", "Validação, publicação e documentação",
], ],
pricing: "Proposta por projeto", firstStep:
pricingNote: "Escopo, etapas e investimento definidos antes da execução.", "Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
featured: true, featured: true,
ctaLabel: "Conversar sobre meu sistema", ctaLabel: "Conversar sobre meu sistema",
ctaHref: "/#contact", ctaHref: "/#contact",
@@ -55,8 +53,8 @@ const TIERS: Tier[] = [
"Análise dos gargalos da aplicação", "Análise dos gargalos da aplicação",
"Plano de correção por prioridade", "Plano de correção por prioridade",
], ],
pricing: "Escopo de análise definido", firstStep:
pricingNote: "Profundidade e prazo combinados conforme o sistema.", "Você me mostra o que já existe. Depois, recebe o levantamento de riscos e o plano de correção por prioridade.",
ctaLabel: "Avaliar meu projeto", ctaLabel: "Avaliar meu projeto",
ctaHref: "/#contact", ctaHref: "/#contact",
}, },
@@ -73,9 +71,8 @@ const TIERS: Tier[] = [
"Testes das partes afetadas", "Testes das partes afetadas",
"Planejamento de manutenção e operação", "Planejamento de manutenção e operação",
], ],
pricing: "Etapas acordadas", firstStep:
pricingNote: "Primeira conversa sobre o sistema e as ferramentas que você já usa. Depois, escopo e proposta por escrito.",
"Prioridades, suporte e responsabilidades definidos em contrato.",
ctaLabel: "Planejar a próxima etapa", ctaLabel: "Planejar a próxima etapa",
ctaHref: "/#contact", ctaHref: "/#contact",
}, },
@@ -105,26 +102,14 @@ const PROCESS = [
]; ];
function TierCard({ tier }: Readonly<{ tier: Tier }>) { function TierCard({ tier }: Readonly<{ tier: Tier }>) {
const shouldReduceMotion = useReducedMotion();
return ( return (
<motion.article <article
className={`ds-card relative flex h-full min-w-0 flex-col overflow-hidden outline-none max-md:w-full max-md:max-w-none max-md:px-sp-5 max-md:py-sp-5 ${ className={`ds-card cv-press cv-lift relative flex h-full min-w-0 flex-col overflow-hidden max-md:w-full max-md:max-w-none max-md:px-sp-5 max-md:py-sp-5 ${
tier.featured ? "border-[var(--slash)] shadow-sm" : "" tier.featured ? "border-[var(--slash)] shadow-sm" : ""
}`} }`}
style={{ style={{
borderWidth: tier.featured ? "2px" : "1px", borderWidth: tier.featured ? "2px" : "1px",
}} }}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 24px 50px rgba(0, 0, 0, 0.08)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
> >
<div className="flex min-w-0 items-start justify-between gap-sp-4"> <div className="flex min-w-0 items-start justify-between gap-sp-4">
<span className="ds-kicker">{tier.badge}</span> <span className="ds-kicker">{tier.badge}</span>
@@ -136,20 +121,20 @@ 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]"> <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} {tier.title}
</h3> </h3>
<p className="mt-sp-2 text-[13px] leading-[1.7] text-[var(--meta)]"> <p className="mt-sp-2 text-[15px] md:text-[13px] leading-[1.7] text-[var(--meta)]">
{tier.tagline} {tier.tagline}
</p> </p>
</div> </div>
<p <p
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[13px]" className="ds-copy !mt-sp-4 !text-[15px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[13px]"
data-reel="right" data-reel="right"
> >
{tier.description} {tier.description}
</p> </p>
<ul <ul
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[15px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel-stagger="fade" data-reel-stagger="fade"
> >
{tier.deliverables.map((deliverable) => ( {tier.deliverables.map((deliverable) => (
@@ -161,10 +146,10 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<div className="mt-sp-6 space-y-sp-2 border-t border-[var(--border)] pt-sp-5"> <div className="mt-sp-6 space-y-sp-2 border-t border-[var(--border)] pt-sp-5">
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight"> <div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
{tier.pricing} Primeiro passo
</div> </div>
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]"> <div className="font-display text-[15px] md:text-[13px] leading-[1.7] text-[var(--ink-80)]">
{tier.pricingNote} {tier.firstStep}
</div> </div>
</div> </div>
@@ -176,41 +161,27 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
> >
{tier.ctaLabel} {tier.ctaLabel}
</Link> </Link>
</motion.article> </article>
); );
} }
/** A home mostra só os três serviços principais, nesta ordem: sites, sistemas web e integrações. O hub tem os demais. */
const HOME_SERVICE_SLUGS = [
"criacao-de-sites",
"engenharia-de-software",
"integracao-hardware-software-iot",
];
export function WorkModalities({ export function WorkModalities({
serviceLinks = [], serviceLinks = [],
}: { }: {
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */ /** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
serviceLinks?: ServiceLink[]; serviceLinks?: ServiceLink[];
}) { }) {
const shouldReduceMotion = useReducedMotion(); const mainServices = HOME_SERVICE_SLUGS.flatMap((slug) => {
const service = serviceLinks.find(({ href }) => href.endsWith(`/${slug}`));
const containerVariants: Variants = { return service ? [service] : [];
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.75, ease: [0.16, 1, 0.3, 1] },
},
};
return ( return (
<Section <Section
@@ -235,15 +206,15 @@ export function WorkModalities({
</p> </p>
</div> </div>
<Link href="/servicos" className={`shrink-0 ${secondaryButtonLight}`}> <Link href="/servicos" className={`shrink-0 ${secondaryButtonLight}`}>
Conhecer todos os serviços Ver todos os serviços
</Link> </Link>
</div> </div>
<div <div
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3" className="mt-sp-6 grid gap-sp-3 md:grid-cols-3"
data-reel-stagger="left" data-reel-stagger="left"
> >
{serviceLinks.map((service) => ( {mainServices.map((service) => (
<Link <Link
key={service.href} key={service.href}
href={service.href} href={service.href}
@@ -252,7 +223,7 @@ export function WorkModalities({
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]"> <div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
{service.label} {service.label}
</div> </div>
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]"> <div className="mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[var(--ink-80)]">
{service.description} {service.description}
</div> </div>
</Link> </Link>
@@ -260,19 +231,19 @@ export function WorkModalities({
</div> </div>
</div> </div>
<motion.div <div className="grid grid-cols-1 gap-sp-4 md:grid-cols-2 md:gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
className="grid grid-cols-1 gap-sp-4 md:grid-cols-2 md:gap-sp-5 lg:grid-cols-3 lg:gap-sp-6" {TIERS.map((tier, index) => (
initial="hidden" <Reveal
whileInView="visible" key={tier.id}
viewport={{ once: true, amount: 0.2 }} y={20}
variants={containerVariants} duration={0.75}
delay={0.08 * (index + 1)}
className="min-w-0"
> >
{TIERS.map((tier) => (
<motion.div key={tier.id} variants={itemVariants} className="min-w-0">
<TierCard tier={tier} /> <TierCard tier={tier} />
</motion.div> </Reveal>
))} ))}
</motion.div> </div>
<div id="process" className="mt-sp-12 scroll-mt-24"> <div id="process" className="mt-sp-12 scroll-mt-24">
<h3 className="ds-card-title">Como o projeto acontece</h3> <h3 className="ds-card-title">Como o projeto acontece</h3>
@@ -6,7 +6,7 @@ import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/** /**
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site, * 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. * presente sem site, ausente), a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele vem do layout) e a ligação dos hooks.
* As cenas e os hooks são trocados por dublês que registram o que recebem. * As cenas e os hooks são trocados por dublês que registram o que recebem.
*/ */
@@ -14,7 +14,6 @@ const mockHeroProps = jest.fn();
const mockProjectsProps = jest.fn(); const mockProjectsProps = jest.fn();
const mockModalitiesProps = jest.fn(); const mockModalitiesProps = jest.fn();
const mockTestimonialsProps = jest.fn(); const mockTestimonialsProps = jest.fn();
const mockFooterProps = jest.fn();
jest.mock("../Hero", () => ({ jest.mock("../Hero", () => ({
__esModule: true, __esModule: true,
@@ -44,6 +43,10 @@ jest.mock("../EngineeringWithAI", () => ({
__esModule: true, __esModule: true,
default: () => <section data-testid="scene-ai" />, default: () => <section data-testid="scene-ai" />,
})); }));
jest.mock("../HomeFaq", () => ({
__esModule: true,
default: () => <section data-testid="scene-faq" />,
}));
jest.mock("../Testimonials", () => ({ jest.mock("../Testimonials", () => ({
__esModule: true, __esModule: true,
default: (props: unknown) => { default: (props: unknown) => {
@@ -55,17 +58,6 @@ jest.mock("../Contact", () => ({
__esModule: true, __esModule: true,
default: () => <section data-testid="scene-contact" />, 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("./useCinematicScroll", () => ({ useCinematicScroll: jest.fn() }));
jest.mock("./useHomeFragmentNavigation", () => ({ jest.mock("./useHomeFragmentNavigation", () => ({
useHomeFragmentNavigation: jest.fn(), useHomeFragmentNavigation: jest.fn(),
@@ -86,27 +78,24 @@ beforeEach(() => {
}); });
describe("CinematicHome: endereço do trabalho em destaque", () => { 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", () => { it("path: the featured project (SEV) is in the list with its own domain -> that domain goes to the hero", () => {
render( render(
<CinematicHome <CinematicHome
projects={[ projects={[
project("lavacao-047", "https://www.landing-lavacao.example.com/"),
project("sev-modaintima", "https://sevmodaintima.com.br"), project("sev-modaintima", "https://sevmodaintima.com.br"),
project(
"lavacao-047",
"https://www.landing-lavacao.vercel.app/pagina/",
),
]} ]}
/>, />,
); );
expect(lastHeroProps().workUrl).toEqual({ expect(lastHeroProps().workUrl).toEqual({
text: "landing-lavacao.vercel.app/pagina", text: "sevmodaintima.com.br",
secure: true, secure: true,
}); });
}); });
it("path: the featured project is in the list but has no site -> no address", () => { it("path: the featured project is in the list but has no site -> no address", () => {
render(<CinematicHome projects={[project("lavacao-047", null)]} />); render(<CinematicHome projects={[project("sev-modaintima", null)]} />);
expect(lastHeroProps().workUrl).toBeNull(); expect(lastHeroProps().workUrl).toBeNull();
}); });
@@ -114,7 +103,19 @@ describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project is not in the list -> no address", () => { it("path: the featured project is not in the list -> no address", () => {
render( render(
<CinematicHome <CinematicHome
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]} projects={[project("lavacao-047", "https://lavacao.example.com")]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project only has a hosting address (vercel.app) -> no address bar text", () => {
render(
<CinematicHome
projects={[
project("sev-modaintima", "https://sev-moda-intima.vercel.app/"),
]}
/>, />,
); );
@@ -154,7 +155,6 @@ describe("CinematicHome: props e estrutura", () => {
expect(mockProjectsProps).toHaveBeenCalledWith({ projects }); expect(mockProjectsProps).toHaveBeenCalledWith({ projects });
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: testimonials }); expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: testimonials });
expect(mockModalitiesProps).toHaveBeenCalledWith({ serviceLinks }); expect(mockModalitiesProps).toHaveBeenCalledWith({ serviceLinks });
expect(mockFooterProps).toHaveBeenCalledWith({ serviceLinks });
}); });
it("path: optional props omitted -> the scenes receive undefined and decide their own defaults", () => { it("path: optional props omitted -> the scenes receive undefined and decide their own defaults", () => {
@@ -168,10 +168,9 @@ describe("CinematicHome: props e estrutura", () => {
expect(mockModalitiesProps).toHaveBeenCalledWith({ expect(mockModalitiesProps).toHaveBeenCalledWith({
serviceLinks: undefined, serviceLinks: undefined,
}); });
expect(mockFooterProps).toHaveBeenCalledWith({ serviceLinks: undefined });
}); });
it("path: scenes sit in order inside <main>; the footer and the floating button sit outside it", () => { it("path: scenes sit in order inside <main> (FAQ right before the contact); the home has no footer of its own (it comes from the layout)", () => {
render(<CinematicHome projects={[]} />); render(<CinematicHome projects={[]} />);
const main = screen.getByRole("main"); const main = screen.getByRole("main");
@@ -186,10 +185,12 @@ describe("CinematicHome: props e estrutura", () => {
"scene-modalities", "scene-modalities",
"scene-ai", "scene-ai",
"scene-testimonials", "scene-testimonials",
"scene-faq",
"scene-contact", "scene-contact",
]); ]);
expect(main).not.toContainElement(screen.getByTestId("footer")); // o rodapé é do layout: a home não desenha um (seria o segundo da página)
expect(main).not.toContainElement(screen.getByTestId("floating-whatsapp")); expect(screen.queryByRole("contentinfo")).toBeNull();
expect(document.querySelector("footer")).toBeNull();
}); });
it("path: mount -> the scroll hook gets the <main> ref and the layout-ready callback runs once", () => { it("path: mount -> the scroll hook gets the <main> ref and the layout-ready callback runs once", () => {
+11 -11
View File
@@ -8,9 +8,8 @@ import About from "../About";
import type { ClientLink } from "../ClientStrip"; import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact"; import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI"; import EngineeringWithAI from "../EngineeringWithAI";
import FloatingWhatsApp from "../FloatingWhatsApp";
import Footer from "../Footer";
import Hero, { type HeroStats } from "../Hero"; import Hero, { type HeroStats } from "../Hero";
import HomeFaq from "../HomeFaq";
import Projects from "../Projects"; import Projects from "../Projects";
import { displayUrl } from "../showcase/BrowserFrame"; import { displayUrl } from "../showcase/BrowserFrame";
import Testimonials from "../Testimonials"; import Testimonials from "../Testimonials";
@@ -18,8 +17,8 @@ import { WorkModalities } from "../WorkModalities";
import { useCinematicScroll } from "./useCinematicScroll"; import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation"; import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/** Projeto cujo endereço aparece na barra do navegador de HeroWork. */ /** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */
const HERO_WORK_PROJECT = "lavacao-047"; const HERO_WORK_PROJECT = "sev-modaintima";
export default function CinematicHome({ export default function CinematicHome({
projects, projects,
@@ -36,10 +35,13 @@ export default function CinematicHome({
/** Serviços do hub; lib/site-content.ts só pode ser lido no servidor, então a página os entrega prontos. */ /** Serviços do hub; lib/site-content.ts só pode ser lido no servidor, então a página os entrega prontos. */
serviceLinks?: ServiceLink[]; serviceLinks?: ServiceLink[];
}) { }) {
const heroWorkUrl = displayUrl({ const shownUrl = displayUrl({
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT) demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
?.demoUrl, ?.demoUrl,
}); });
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
const heroWorkUrl =
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
const onLayoutReady = useHomeFragmentNavigation(); const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null); const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef); useCinematicScroll(mainRef);
@@ -47,8 +49,8 @@ export default function CinematicHome({
useEffect(() => onLayoutReady(), [onLayoutReady]); useEffect(() => onLayoutReady(), [onLayoutReady]);
return ( return (
<div className="flex min-h-screen flex-col overflow-x-clip"> <div className="overflow-x-clip">
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */} {/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. O rodapé vem do layout. */}
<main ref={mainRef} className="ds-main relative z-0"> <main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} /> <Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
@@ -62,12 +64,10 @@ export default function CinematicHome({
<Testimonials items={testimonials} /> <Testimonials items={testimonials} />
<HomeFaq />
<Contact /> <Contact />
</main> </main>
<div className="relative z-10">
<Footer serviceLinks={serviceLinks} />
</div>
<FloatingWhatsApp />
</div> </div>
); );
} }
+42
View File
@@ -27,3 +27,45 @@
letter-spacing: normal; letter-spacing: normal;
text-transform: none; text-transform: none;
} }
/*
* Retorno de toque dos cartões que não são clicáveis. O whileTap do motion fazia isso, mas também punha
* tabindex="0" em cada cartão (7 paradas de foco sem ação, 3 delas sem contorno). A propriedade `scale` se soma
* ao transform que o motion escreve no estilo inline, em vez de ser sobrescrita por ele.
*/
.cv-press {
transition: scale 0.15s ease-out;
}
.cv-press:active {
scale: 0.99;
}
@media (prefers-reduced-motion: reduce) {
.cv-press {
transition: none;
}
.cv-press:active {
scale: 1;
}
}
/*
* Elevação dos cartões ao passar o mouse (era o whileHover do motion): só com mouse e sem movimento reduzido.
* Usa `translate`, que não disputa o `transform` com a entrada por rolagem (.ds-reveal fica no envoltório),
* e repete a transição de `scale` do .cv-press, que esta regra substitui.
*/
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.cv-lift {
transition:
scale 0.15s ease-out,
translate 0.35s var(--ease-out),
box-shadow 0.35s var(--ease-out);
}
.cv-lift:hover {
translate: 0 -4px;
box-shadow: 0 20px 44px rgba(10, 10, 10, 0.12);
}
}
+27
View File
@@ -0,0 +1,27 @@
import manifest from "./manifest";
import { alt as openGraphAlt } from "./opengraph-image";
/**
* A home se apresenta como "Sites e sistemas que trazem pedidos pelo WhatsApp e tiram a operação da planilha".
* O manifesto e a imagem de compartilhamento precisam dizer o mesmo, não o posicionamento antigo ("sistemas sob medida").
*/
jest.mock("next/og", () => ({ ImageResponse: jest.fn() }));
describe("manifesto do site", () => {
it("fala de sites e sistemas, não do posicionamento antigo", () => {
const { name, description } = manifest();
expect(name).toMatch(/Sites e sistemas/);
expect(description).toMatch(/WhatsApp/);
expect(description).toMatch(/planilha/);
expect(`${name} ${description}`).not.toMatch(/sob medida/i);
});
});
describe("imagem de compartilhamento da home", () => {
it("tem o texto alternativo do novo posicionamento", () => {
expect(openGraphAlt).toMatch(/Sites e sistemas/);
expect(openGraphAlt).not.toMatch(/sob medida/i);
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { ImageResponse } from "next/og"; import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card"; 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 alt = "Revisão de app feito com IA";
export const size = OG_SIZE; export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE; export const contentType = OG_CONTENT_TYPE;
@@ -9,8 +9,8 @@ export default function Image() {
return new ImageResponse( return new ImageResponse(
<OgCard <OgCard
eyebrow="IA no desenvolvimento" eyebrow="IA no desenvolvimento"
title="Começou um sistema com IA? Dá para partir daí." title="Revisão de app feito com IA"
description="Revisão do que foi gerado, correção dos riscos e continuidade até a produção." description="Falhas de acesso a dados, o que aproveitar, o que refazer e um orçamento para pôr em produção."
/>, />,
OG_SIZE, OG_SIZE,
); );
+61 -15
View File
@@ -1,22 +1,26 @@
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import AiPage, { metadata } from "./page"; import AiPage, { metadata } from "./page";
it("uses its own title without the site suffix being added twice", () => { it("uses a short search title: with the layout suffix it fits in 44 characters", () => {
expect(metadata.title).toEqual({ // o template de app/layout.tsx acrescenta " | Evandro.dev.br"
absolute: const suffix = " | Evandro.dev.br";
"Começou um sistema com IA? Revisão e continuidade | Evandro Fonseca Junior", expect(metadata.title).toBe("Revisão de app feito com IA");
}); expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(44);
expect(metadata).toMatchObject({ expect(metadata).toMatchObject({
description: expect.any(String), description: expect.any(String),
alternates: { canonical: "https://evandro.dev.br/ia" }, alternates: { canonical: "https://evandro.dev.br/ia" },
}); });
// as prévias de compartilhamento não passam pelo template: o título delas já leva o sufixo
expect(metadata.openGraph?.title).toBe(
`Revisão de app feito com IA${suffix}`,
);
}); });
it("keeps the full AI content, with the cited incidents summarized only here", () => { it("keeps the full AI content, with the cited incidents summarized only here", () => {
render(<AiPage />); render(<AiPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Começou um sistema com IA? Dá para partir daí.", "Revisão de app feito com IA (Lovable, Bolt, Replit)",
); );
for (const heading of [ for (const heading of [
"Se a IA cria um sistema, por que contratar um desenvolvedor?", "Se a IA cria um sistema, por que contratar um desenvolvedor?",
@@ -25,7 +29,7 @@ it("keeps the full AI content, with the cited incidents summarized only here", (
]) { ]) {
expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument();
} }
for (const incident of [/Lovable/, /Replit/, /Base44/]) { for (const incident of [/Lovable:/, /Replit:/, /Base44:/]) {
expect(screen.getByRole("heading", { name: incident })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: incident })).toBeInTheDocument();
} }
expect( expect(
@@ -40,16 +44,16 @@ it("offers WhatsApp conversations about reviewing an AI-built system", () => {
.getAllByRole("link") .getAllByRole("link")
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/")); .filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
expect(links.map((link) => link.textContent)).toEqual([ expect(links.map((link) => link.textContent)).toEqual([
"Falar sobre meu sistema", "Pedir a revisão do meu app",
"Avaliar meu projeto", "Avaliar meu projeto",
]); ]);
for (const link of links) { for (const link of links) {
const text = new URL(link.getAttribute("href") ?? "").searchParams.get( const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
"text", "text",
); );
// a mensagem tem de formar uma frase: "Quero conversar sobre a revisão de um sistema feito com IA." // a mensagem tem de formar uma frase: "Quero conversar sobre a revisão de um aplicativo feito com IA."
expect(text).toContain( expect(text).toContain(
"Quero conversar sobre a revisão de um sistema feito com IA.", "Quero conversar sobre a revisão de um aplicativo feito com IA.",
); );
} }
}); });
@@ -57,12 +61,54 @@ it("offers WhatsApp conversations about reviewing an AI-built system", () => {
it("has breadcrumb navigation back to the home", () => { it("has breadcrumb navigation back to the home", () => {
render(<AiPage />); render(<AiPage />);
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" }); const breadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
expect( expect(
within(breadcrumb).getByRole("link", { name: "Início" }), within(breadcrumb).getByRole("link", { name: "Início" }),
).toHaveAttribute("href", "/"); ).toHaveAttribute("href", "/");
expect(within(breadcrumb).getByText("IA no desenvolvimento")).toHaveAttribute( expect(
"aria-current", within(breadcrumb).getByText("Revisão de app feito com IA"),
"page", ).toHaveAttribute("aria-current", "page");
); });
it("presents the review as an offer: what the person receives, with no price or deadline", () => {
const { container } = render(<AiPage />);
const offer = screen.getByRole("heading", {
name: "O que você recebe na revisão",
});
const section = offer.closest("section") as HTMLElement;
const items = within(section)
.getAllByRole("listitem")
.map((item) => item.querySelector("h3")?.textContent);
expect(items).toEqual([
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]);
expect(container.textContent).not.toMatch(/R\$|\d+ dias|em até \d+/);
});
it("keeps the incident news collapsed in a <details>, closed by default", () => {
const { container } = render(<AiPage />);
const heading = screen.getByRole("heading", {
name: "O que casos documentados ensinam",
});
const details = heading.closest("details") as HTMLDetailsElement;
expect(details).not.toBeNull();
expect(details.open).toBe(false);
expect(heading.closest("summary")).not.toBeNull();
for (const incident of [/Lovable:/, /Replit:/, /Base44:/]) {
expect(within(details).getByText(incident)).toBeInTheDocument();
}
// só os incidentes ficam recolhidos: a oferta e a LGPD ficam à vista
expect(container.querySelectorAll("details")).toHaveLength(1);
expect(
screen
.getByRole("heading", { name: "O que você recebe na revisão" })
.closest("details"),
).toBeNull();
}); });
+87 -18
View File
@@ -10,26 +10,51 @@ import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld"; import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content"; import { siteUrl } from "@/lib/site-content";
import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp";
const pageUrl = `${siteUrl}/ia`; const pageUrl = `${siteUrl}/ia`;
const pageTitle = /** Curto de propósito: o template de app/layout.tsx acrescenta " | Evandro.dev.br" e o conjunto cabe em 44 caracteres. */
"Começou um sistema com IA? Revisão e continuidade | Evandro Fonseca Junior"; const pageTitle = "Revisão de app feito com IA";
const pageDescription = 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."; "Fez um app com Lovable, Bolt ou Replit? Eu reviso as falhas de acesso a dados, separo o que aproveitar do que refazer e faço o orçamento para a produção.";
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp. */ /** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp (a mesma do botão flutuante em /ia). */
const WHATSAPP_SERVICE = "a revisão de um sistema feito com IA"; const WHATSAPP_SERVICE = AI_REVIEW_SUBJECT;
export const metadata: Metadata = { export const metadata: Metadata = {
title: { absolute: pageTitle }, title: pageTitle,
description: pageDescription, description: pageDescription,
...buildPageSocialMetadata({ ...buildPageSocialMetadata({
url: pageUrl, url: pageUrl,
title: pageTitle, // as prévias de compartilhamento não passam pelo template do título
title: `${pageTitle} | Evandro.dev.br`,
description: pageDescription, description: pageDescription,
type: "website", type: "website",
}), }),
}; };
/** O que a pessoa recebe ao contratar a revisão. Sem prazo e sem preço: isso se combina na conversa. */
const DELIVERABLES = [
{
title: "As falhas de acesso a dados",
description:
"Testo se uma pessoa consegue ver ou alterar os dados de outra e mostro o que precisa de correção.",
},
{
title: "O que dá para aproveitar",
description: "O que já está pronto e pode seguir no projeto.",
},
{
title: "O que é melhor refazer",
description:
"As partes que não se sustentam em uso real, com o motivo de cada uma.",
},
{
title: "Um orçamento para pôr em produção",
description:
"O que falta para o app atender clientes de verdade, com o orçamento por escrito.",
},
];
const RESPONSIBILITIES = [ const RESPONSIBILITIES = [
{ {
title: "Quem pode acessar cada dado?", title: "Quem pode acessar cada dado?",
@@ -98,7 +123,7 @@ export default function AiPage() {
"@graph": [ "@graph": [
buildBreadcrumbList(pageUrl, [ buildBreadcrumbList(pageUrl, [
["Início", siteUrl], ["Início", siteUrl],
["IA no desenvolvimento", pageUrl], ["Revisão de app feito com IA", pageUrl],
] as const), ] as const),
], ],
}; };
@@ -112,12 +137,12 @@ export default function AiPage() {
<Breadcrumb <Breadcrumb
items={[ items={[
{ label: "Início", href: "/" }, { label: "Início", href: "/" },
{ label: "IA no desenvolvimento", href: "/ia" }, { label: "Revisão de app feito com IA", href: "/ia" },
]} ]}
/> />
<p className="ds-eyebrow">IA no desenvolvimento</p> <p className="ds-eyebrow">IA no desenvolvimento</p>
<h1 className="ds-section-title max-w-[22ch] text-balance"> <h1 className="ds-section-title max-w-[22ch] text-balance">
Começou um sistema com IA? Dá para partir daí. Revisão de app feito com IA (Lovable, Bolt, Replit)
</h1> </h1>
<p className="ds-copy max-w-[62ch]"> <p className="ds-copy max-w-[62ch]">
A IA permite testar ideias e construir protótipos mesmo sem A IA permite testar ideias e construir protótipos mesmo sem
@@ -131,7 +156,7 @@ export default function AiPage() {
service={WHATSAPP_SERVICE} service={WHATSAPP_SERVICE}
placement="ia_hero" placement="ia_hero"
> >
Falar sobre meu sistema Pedir a revisão do meu app
</WhatsAppLink> </WhatsAppLink>
<Link href="/cases" className={textLinkDark}> <Link href="/cases" className={textLinkDark}>
Ver projetos entregues Ver projetos entregues
@@ -140,6 +165,34 @@ export default function AiPage() {
</div> </div>
</section> </section>
<section className="ds-section" aria-labelledby="ia-receber-title">
<div className="ds-section-shell">
<h2 id="ia-receber-title" className="ds-section-title max-w-[26ch]">
O que você recebe na revisão
</h2>
<ol className="mt-sp-8 grid gap-sp-6 sm:grid-cols-2 lg:grid-cols-4">
{DELIVERABLES.map((item, index) => (
<li
key={item.title}
className="border-t border-[var(--border)] pt-sp-5"
>
<span className="ds-kicker">
{String(index + 1).padStart(2, "0")}
</span>
<h3 className="mt-sp-3 text-lg font-semibold leading-snug">
{item.title}
</h3>
<p className="ds-copy">{item.description}</p>
</li>
))}
</ol>
<p className="ds-copy mt-sp-8 max-w-[65ch]">
Começa por uma conversa pelo WhatsApp, para eu entender o app e o
que ele precisa fazer. Não envie senhas nem acessos por mensagem.
</p>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-contratar-title"> <section className="ds-section" aria-labelledby="ia-contratar-title">
<div className="ds-section-shell"> <div className="ds-section-shell">
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]"> <h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">
@@ -171,14 +224,28 @@ export default function AiPage() {
<section className="ds-section" aria-labelledby="ia-casos-title"> <section className="ds-section" aria-labelledby="ia-casos-title">
<div className="ds-section-shell"> <div className="ds-section-shell">
<h2 id="ia-casos-title" className="ds-section-title max-w-[26ch]"> {/* Recolhido: as notícias sustentam a oferta, mas não são o que a pessoa veio ver. */}
<details className="group border-y border-[var(--border)]">
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
<h2
id="ia-casos-title"
className="font-display text-[clamp(20px,3vw,28px)] font-semibold leading-tight tracking-[-0.02em]"
>
O que casos documentados ensinam O que casos documentados ensinam
</h2> </h2>
<span
aria-hidden="true"
className="shrink-0 font-mono text-2xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</summary>
<div className="pb-sp-8">
<p className="ds-copy max-w-[75ch]"> <p className="ds-copy max-w-[75ch]">
São episódios de 2025, não avaliações atuais das plataformas. Eles São episódios de 2025, não avaliações atuais das plataformas.
mostram riscos de configuração, permissões e operação; não provam Eles mostram riscos de configuração, permissões e operação; não
que todo sistema feito com IA seja inseguro ou que um profissional provam que todo sistema feito com IA seja inseguro ou que um
elimine qualquer risco. profissional elimine qualquer risco.
</p> </p>
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3"> <div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
{INCIDENTS.map((incident) => ( {INCIDENTS.map((incident) => (
@@ -192,7 +259,7 @@ export default function AiPage() {
{incident.links.map((link) => ( {incident.links.map((link) => (
<li key={link.href}> <li key={link.href}>
<a <a
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4" className="inline-flex min-h-11 items-center text-[15px] md:text-sm underline underline-offset-4"
href={link.href} href={link.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -206,6 +273,8 @@ export default function AiPage() {
))} ))}
</div> </div>
</div> </div>
</details>
</div>
</section> </section>
<section className="ds-section" aria-labelledby="ia-lgpd-title"> <section className="ds-section" aria-labelledby="ia-lgpd-title">
@@ -229,7 +298,7 @@ export default function AiPage() {
adequação. adequação.
</p> </p>
<a <a
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4" className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] md: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" 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" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
+150
View File
@@ -0,0 +1,150 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { caseStudies, serviceHubLinks, servicePages } from "@/lib/site-content";
import RootLayout, { metadata } from "./layout";
/**
* Metadados do layout (título e sufixo, canônico) e o que ele monta em todas as páginas: o cabeçalho, o rodapé e o botão
* flutuante do WhatsApp. Cabeçalho e botão recebem os nomes dos serviços e dos cases, lidos de lib/site-content.ts no servidor.
*/
const mockHeaderProps = jest.fn();
const mockFloatingProps = jest.fn();
const mockFooterProps = jest.fn();
jest.mock("next/font/google", () => ({
Geist_Mono: () => ({ variable: "font-mono-var" }),
Space_Grotesk: () => ({ variable: "font-display-var" }),
}));
jest.mock("./globals.css", () => ({}));
jest.mock("./components/cinematic/CinematicOverlays", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("./components/GoogleAnalytics", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("./components/Header", () => ({
Header: (props: unknown) => {
mockHeaderProps(props);
return <header data-testid="header" />;
},
}));
jest.mock("./components/Footer", () => ({
__esModule: true,
default: (props: unknown) => {
mockFooterProps(props);
return <footer data-testid="footer" />;
},
}));
jest.mock("./components/FloatingWhatsApp", () => ({
__esModule: true,
default: (props: unknown) => {
mockFloatingProps(props);
return <div data-testid="floating-whatsapp" />;
},
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe("metadata", () => {
it("ends every page title with ' | Evandro.dev.br' and gives the home its own default title", () => {
expect(metadata.title).toEqual({
default: "Sites e sistemas em Joinville e SC | Evandro.dev.br",
template: "%s | Evandro.dev.br",
});
});
it("keeps the home title, suffix included, within 60 characters", () => {
const title = metadata.title as { default: string };
expect(title.default.length).toBeLessThanOrEqual(60);
});
it("does not set a canonical in the layout: only pages that know their own address do", () => {
// o 404 e as páginas sem canônico próprio não podem herdar o "/" da home
expect(metadata.alternates).toBeUndefined();
});
it("describes the offer without inventing numbers or promises", () => {
expect(metadata.description).toMatch(/WhatsApp/);
expect(metadata.description).not.toMatch(/R\$|garantia|\d+ dias/);
});
});
describe("what the layout mounts", () => {
function markup() {
return renderToStaticMarkup(
<RootLayout>
<main>conteúdo</main>
</RootLayout>,
);
}
it("mounts the floating WhatsApp button for the whole site, outside the home", () => {
const html = markup();
expect(html).toContain('data-testid="floating-whatsapp"');
expect(html.indexOf("conteúdo")).toBeLessThan(
html.indexOf('data-testid="floating-whatsapp"'),
);
});
it("mounts the footer once for the whole site, after the page content, with the service links", () => {
const html = markup();
expect(html.match(/data-testid="footer"/g)).toHaveLength(1);
expect(html.indexOf("conteúdo")).toBeLessThan(
html.indexOf('data-testid="footer"'),
);
expect(mockFooterProps).toHaveBeenCalledWith({
serviceLinks: serviceHubLinks,
});
expect(serviceHubLinks.length).toBeGreaterThan(0);
});
it("gives the header and the button the service and case names, and only names", () => {
markup();
const expected = {
services: Object.fromEntries(
servicePages.map((service) => [service.slug, service.navLabel]),
),
cases: Object.fromEntries(
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
),
};
expect(mockFloatingProps).toHaveBeenCalledWith({ contexts: expected });
expect(mockHeaderProps).toHaveBeenCalledWith({
whatsAppContexts: expected,
});
expect(Object.keys(expected.services).length).toBeGreaterThan(0);
expect(Object.keys(expected.cases).length).toBeGreaterThan(0);
});
it("hides the JavaScript-only parts of the page when scripts are off", () => {
expect(markup()).toContain(".js-only{display:none!important}");
});
});
describe("fonts", () => {
it("no longer loads the unused Monument Extended font", () => {
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
expect(source).not.toMatch(/Monument|next\/font\/local/i);
expect(markupClassName()).not.toMatch(/monument/i);
});
function markupClassName() {
return (
renderToStaticMarkup(
<RootLayout>
<main />
</RootLayout>,
).match(/<html[^>]*class="([^"]*)"/)?.[1] ?? ""
);
}
});
+38 -30
View File
@@ -1,18 +1,41 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Geist_Mono, Space_Grotesk } from "next/font/google"; import { Geist_Mono, Space_Grotesk } from "next/font/google";
import localFont from "next/font/local";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { safeJsonLd } from "@/lib/jsonld"; import { safeJsonLd } from "@/lib/jsonld";
import { buildPersonJsonLd, buildWebSiteJsonLd } from "@/lib/seo"; import { buildPersonJsonLd, buildWebSiteJsonLd } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content"; import {
caseStudies,
serviceHubLinks,
servicePages,
siteUrl,
} from "@/lib/site-content";
import type { WhatsAppContexts } from "@/lib/whatsapp";
import CinematicOverlays from "./components/cinematic/CinematicOverlays"; import CinematicOverlays from "./components/cinematic/CinematicOverlays";
import FloatingWhatsApp from "./components/FloatingWhatsApp";
import Footer from "./components/Footer";
import GoogleAnalytics from "./components/GoogleAnalytics"; import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header"; import { Header } from "./components/Header";
import "./globals.css"; import "./globals.css";
// O CSS da vitrine (molduras de navegador e de celular, vitrine animada) vem uma vez só, aqui, depois do global.
// Importado por cada componente que o usa, ele era pré-carregado pelos links do menu em páginas que não o usam
// (aviso de CSS pré-carregado e não usado).
import "./components/showcase/showcase.css";
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br"; const titleSuffix = " | Evandro.dev.br";
/** Título da home: o `title.default` não passa pelo template, então leva o sufixo. Com ele, no máximo 60 caracteres. */
const defaultTitle = `Sites e sistemas em Joinville e SC${titleSuffix}`;
const seoDescription = const seoDescription =
"Sistemas web, aplicativos e integrações sob medida, do negócio à produção, com Evandro Fonseca Junior. Base em Santa Catarina, atendimento remoto."; "Sites com pedido pelo WhatsApp e sistemas que tiram sua operação da planilha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil.";
/** Para o WhatsApp falar do serviço ou do case da página aberta: só nomes, sem o conteúdo pesado das páginas. */
const whatsAppContexts: WhatsAppContexts = {
services: Object.fromEntries(
servicePages.map((service) => [service.slug, service.navLabel]),
),
cases: Object.fromEntries(
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
),
};
const spaceGrotesk = Space_Grotesk({ const spaceGrotesk = Space_Grotesk({
subsets: ["latin"], subsets: ["latin"],
@@ -28,27 +51,10 @@ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
}); });
const monumentExtended = localFont({
src: [
{
path: "../public/fonts/MonumentExtended-Regular.otf",
weight: "400",
style: "normal",
},
{
path: "../public/fonts/MonumentExtended-Ultrabold.otf",
weight: "800",
style: "normal",
},
],
display: "swap",
variable: "--font-monument-extended",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: defaultTitle, default: defaultTitle,
template: "%s | Evandro Fonseca Junior", template: `%s${titleSuffix}`,
}, },
description: seoDescription, description: seoDescription,
authors: [{ name: "Evandro Fonseca Junior" }], authors: [{ name: "Evandro Fonseca Junior" }],
@@ -57,9 +63,6 @@ export const metadata: Metadata = {
applicationName: "Evandro.dev.br", applicationName: "Evandro.dev.br",
category: "Technology", category: "Technology",
metadataBase: new URL(siteUrl), metadataBase: new URL(siteUrl),
alternates: {
canonical: "/",
},
formatDetection: { formatDetection: {
email: false, email: false,
address: false, address: false,
@@ -77,7 +80,7 @@ export const metadata: Metadata = {
url: "/opengraph-image", url: "/opengraph-image",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "Evandro.dev.br | Sistemas sob medida para empresas", alt: "Evandro.dev.br | Sites e sistemas para empresas",
}, },
], ],
}, },
@@ -89,7 +92,7 @@ export const metadata: Metadata = {
images: [ images: [
{ {
url: "/opengraph-image", url: "/opengraph-image",
alt: "Evandro.dev.br | Sistemas sob medida para empresas", alt: "Evandro.dev.br | Sites e sistemas para empresas",
}, },
], ],
}, },
@@ -126,14 +129,14 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
return ( return (
<html <html
lang="pt-BR" lang="pt-BR"
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`} className={`${spaceGrotesk.variable} ${geistMono.variable}`}
> >
<head> <head>
{/* Set arrival policy before native restoration and client-side layout changes. */} {/* Set arrival policy before native restoration and client-side layout changes. */}
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style> <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. */} {/* 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> <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> <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}.js-only{display:none!important}`}</style>
</noscript> </noscript>
<script>{` <script>{`
addEventListener("load", () => { addEventListener("load", () => {
@@ -161,11 +164,16 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
</a> </a>
{/* Aviso de cookies logo depois do "pular": fica cedo na ordem do Tab, ainda que fixo no rodapé da tela. */} {/* Aviso de cookies logo depois do "pular": fica cedo na ordem do Tab, ainda que fixo no rodapé da tela. */}
<GoogleAnalytics /> <GoogleAnalytics />
<Header /> <Header whatsAppContexts={whatsAppContexts} />
<CinematicOverlays /> <CinematicOverlays />
<div id="main-content" tabIndex={-1}> <div id="main-content" tabIndex={-1}>
{children} {children}
</div> </div>
{/* Rodapé de todas as páginas; z-10 o mantém acima das cenas da home (relative z-0). */}
<div className="relative z-10">
<Footer serviceLinks={serviceHubLinks} />
</div>
<FloatingWhatsApp contexts={whatsAppContexts} />
</body> </body>
</html> </html>
); );
+2 -2
View File
@@ -2,10 +2,10 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Evandro.dev.br — Sistemas sob medida para empresas", name: "Evandro.dev.br — Sites e sistemas para empresas",
short_name: "Evandro.dev", short_name: "Evandro.dev",
description: description:
"Desenvolvimento de sistemas web, aplicativos e integrações sob medida. Engenharia de software com Evandro Fonseca Junior, de Santa Catarina para sua empresa.", "Sites que trazem pedidos pelo WhatsApp e sistemas que tiram a operação da planilha. Com Evandro Fonseca Junior, de Garuva, SC, para empresas de todo o Brasil.",
start_url: "/", start_url: "/",
display: "standalone", display: "standalone",
background_color: "#000000", background_color: "#000000",
+4 -4
View File
@@ -1,16 +1,16 @@
import { ImageResponse } from "next/og"; import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card"; import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt = "Evandro.dev.br — Sistemas sob medida para empresas"; export const alt = "Evandro.dev.br — Sites e sistemas para empresas";
export const size = OG_SIZE; export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE; export const contentType = OG_CONTENT_TYPE;
export default async function Image() { export default async function Image() {
return new ImageResponse( return new ImageResponse(
<OgCard <OgCard
eyebrow="Engenharia de software para empresas" eyebrow="Sites e sistemas para empresas"
title="Sistemas sob medida." title="Sites e sistemas que trazem pedidos."
description="Da ideia à produção. Sistemas web, aplicativos e integrações com Evandro Fonseca Junior." description="Pedido pelo WhatsApp e operação fora da planilha. Com Evandro Fonseca Junior, de Garuva, SC."
/>, />,
OG_SIZE, OG_SIZE,
); );
+100
View File
@@ -0,0 +1,100 @@
import type { ReactElement } from "react";
import { portfolio } from "@/lib/portfolio-data";
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
import { clientNames, testimonials } from "@/lib/proof";
import { serviceHubLinks } from "@/lib/site-content";
import CinematicHome from "./components/cinematic/CinematicHome";
import Home from "./page";
/**
* Testes de caminhos-base de Home e de yearsOfExperience (que não é exportada, então passa por Home):
* os anos vêm do texto do portfólio, e a home mostra só projetos de cliente.
*/
jest.mock("./components/cinematic/CinematicHome", () => ({
__esModule: true,
default: jest.fn(() => null),
}));
jest.mock("@/lib/portfolio-data.server", () => ({
loadFeaturedProjects: jest.fn(),
}));
type HomeProps = React.ComponentProps<typeof CinematicHome>;
type Loaded = Awaited<ReturnType<typeof loadFeaturedProjects>>;
const originalSummary = portfolio.personal.summary;
function mockProjects(projects: Array<{ id: string; kind?: string }>) {
jest
.mocked(loadFeaturedProjects)
.mockResolvedValue(projects as unknown as Loaded);
}
async function renderHome() {
const element = (await Home()) as ReactElement<HomeProps>;
expect(element.type).toBe(CinematicHome);
return element.props;
}
beforeEach(() => {
mockProjects([]);
});
afterEach(() => {
portfolio.personal.summary = originalSummary;
});
describe("Home: anos de experiência", () => {
it("path: the summary says '5 anos de experiência' -> 5", async () => {
portfolio.personal.summary = "Desenvolvedor com 5 anos de experiência.";
expect((await renderHome()).heroStats).toEqual({ years: 5 });
});
it("path: more than one digit and different case -> the whole number", async () => {
portfolio.personal.summary = "Atuo há 12 ANOS DE EXPERIÊNCIA em web.";
expect((await renderHome()).heroStats).toEqual({ years: 12 });
});
it("path: the summary has no such stretch -> no number, the hero hides it", async () => {
portfolio.personal.summary = "Desenvolvedor de software em Garuva-SC.";
expect((await renderHome()).heroStats).toEqual({ years: undefined });
});
it("path: a number that is not followed by the experience wording -> ignored", async () => {
portfolio.personal.summary = "Formado há 5 anos, com 3 projetos entregues.";
expect((await renderHome()).heroStats?.years).toBeUndefined();
});
});
describe("Home: projetos e provas", () => {
it("path: client, laboratory and unlabelled projects -> only the client ones reach the page", async () => {
mockProjects([
{ id: "a", kind: "cliente" },
{ id: "b", kind: "laboratorio" },
{ id: "c" },
{ id: "d", kind: "cliente" },
]);
const props = await renderHome();
expect(props.projects.map(({ id }) => id)).toEqual(["a", "d"]);
});
it("path: no featured project at all -> an empty list, the page still renders", async () => {
mockProjects([]);
expect((await renderHome()).projects).toEqual([]);
});
it("path: the proof and the service links come straight from their modules", async () => {
const props = await renderHome();
expect(props.clients).toBe(clientNames);
expect(props.testimonials).toBe(testimonials);
expect(props.serviceLinks).toBe(serviceHubLinks);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { metadata } from "./page";
jest.mock("./components/cinematic/CinematicHome", () => ({
__esModule: true,
default: () => null,
}));
describe("home metadata", () => {
it("leaves the title to the layout default: the template does not apply to the page of its own segment", () => {
expect(metadata.title).toBeUndefined();
});
it("owns the canonical '/', so the 404 page does not inherit it from the layout", () => {
expect(metadata.alternates).toEqual({ canonical: "/" });
});
});
+10
View File
@@ -1,9 +1,19 @@
import type { Metadata } from "next";
import { portfolio } from "@/lib/portfolio-data"; import { portfolio } from "@/lib/portfolio-data";
import { loadFeaturedProjects } from "@/lib/portfolio-data.server"; import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
import { clientNames, isClientProject, testimonials } from "@/lib/proof"; import { clientNames, isClientProject, testimonials } from "@/lib/proof";
import { serviceHubLinks } from "@/lib/site-content"; import { serviceHubLinks } from "@/lib/site-content";
import CinematicHome from "./components/cinematic/CinematicHome"; import CinematicHome from "./components/cinematic/CinematicHome";
/**
* O título da home é o `title.default` de app/layout.tsx ("Sites e sistemas em Joinville e SC | Evandro.dev.br"):
* o template do layout não se aplica à página do mesmo segmento, então não há `title` aqui.
* O canônico fica nesta página, e não no layout, para a página 404 não herdá-lo.
*/
export const metadata: Metadata = {
alternates: { canonical: "/" },
};
/** 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. */ /** 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() { function yearsOfExperience() {
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i); const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
+58
View File
@@ -0,0 +1,58 @@
import nextConfig from "../next.config";
/**
* Testes de caminhos-base de headers() em next.config.ts: o que muda entre produção e desenvolvimento.
* Produção ganha o HSTS e perde o 'unsafe-eval' da política de conteúdo (só o hot reload do dev precisa dele).
*/
async function siteHeaders() {
const rules = (await nextConfig.headers?.()) ?? [];
const site = rules.find(({ source }) => source === "/(.*)");
if (!site) throw new Error("Missing site-wide header rule");
return site.headers;
}
const header = (headers: { key: string; value: string }[], key: string) =>
headers.find((item) => item.key === key)?.value;
afterEach(() => {
jest.restoreAllMocks();
});
describe("next.config headers()", () => {
it("path: production -> HSTS is sent and the policy has no 'unsafe-eval'", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
const headers = await siteHeaders();
expect(header(headers, "Strict-Transport-Security")).toBe(
"max-age=31536000; includeSubDomains; preload",
);
expect(
header(headers, "Content-Security-Policy-Report-Only"),
).not.toContain("'unsafe-eval'");
});
it("path: development or test -> no HSTS, and the policy allows 'unsafe-eval' for the dev server", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
const headers = await siteHeaders();
expect(header(headers, "Strict-Transport-Security")).toBeUndefined();
expect(header(headers, "Content-Security-Policy-Report-Only")).toContain(
"'unsafe-eval'",
);
});
it("path: any environment -> the video rule keeps the long cache", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const videos = rules.find(({ source }) => source === "/videos/:path*");
expect(videos?.headers).toEqual([
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import nextConfig from "../next.config";
describe("next.config", () => {
it("does not announce the framework in the X-Powered-By header", () => {
expect(nextConfig.poweredByHeader).toBe(false);
});
it("keeps the CSP in Report-Only until Google Ads is allowed in it, never enforcing it by accident", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const site = rules.find(({ source }) => source === "/(.*)");
const names = site?.headers.map(({ key }) => key) ?? [];
expect(names).toContain("Content-Security-Policy-Report-Only");
expect(names).not.toContain("Content-Security-Policy");
});
});
+4 -3
View File
@@ -1,12 +1,13 @@
export const CV_URL = "/Profile.pdf"; export const CV_URL = "/Profile.pdf";
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf"; export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
/** "Projetos" saiu: duplicava "Cases". A ordem é a da conversa de venda: prova, oferta, método, quem sou, IA. */
export const NAVIGATION_ITEMS = [ export const NAVIGATION_ITEMS = [
{ href: "/#projects", label: "Projetos" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/cases", label: "Cases" }, { href: "/cases", label: "Cases" },
{ href: "/servicos", label: "Serviços" }, { href: "/servicos", label: "Serviços" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/ia", label: "IA" },
] as const; ] as const;
export const CONTACT_EMAIL = "[email protected]"; export const CONTACT_EMAIL = "[email protected]";
+63
View File
@@ -0,0 +1,63 @@
import {
AI_REVIEW_SUBJECT,
isServiceOrCasePath,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "./whatsapp";
/**
* Testes de caminhos-base de whatsAppSubjectForPath e isServiceOrCasePath (complemento de whatsapp.test.ts):
* o case sem tabela de nomes, as duas tabelas independentes, nome vazio e rotas sem slug.
*/
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
cases: { "sev-modaintima": "SEV Moda Íntima", vazio: "" },
};
describe("whatsAppSubjectForPath", () => {
it("path: /cases/<id> without contexts -> undefined", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima")).toBeUndefined();
expect(
whatsAppSubjectForPath("/cases/sev-modaintima", undefined),
).toBeUndefined();
});
it("path: /ia without contexts and with a trailing slash -> the AI review", () => {
expect(whatsAppSubjectForPath("/ia")).toBe(AI_REVIEW_SUBJECT);
expect(whatsAppSubjectForPath("/ia/", contexts)).toBe(AI_REVIEW_SUBJECT);
});
it("path: a service slug is only looked up in the services, a case id only in the cases", () => {
expect(
whatsAppSubjectForPath("/servicos/sev-modaintima", contexts),
).toBeUndefined();
expect(
whatsAppSubjectForPath("/cases/criacao-de-sites", contexts),
).toBeUndefined();
});
it("path: a case with an empty name -> no subject (the message stays generic)", () => {
expect(whatsAppSubjectForPath("/cases/vazio", contexts)).toBeUndefined();
});
it.each([
"",
"/servicos/",
"/cases/",
"/ia/extra",
"/IA",
"/cases/sev-modaintima/extra",
])("path: %j -> no subject", (pathname) => {
expect(whatsAppSubjectForPath(pathname, contexts)).toBeUndefined();
});
});
describe("isServiceOrCasePath", () => {
it.each(["", "/servicos/", "/cases/", "/cases/a/b", "/curriculo", "/ia/"])(
"path: %j -> false",
(pathname) => {
expect(isServiceOrCasePath(pathname)).toBe(false);
},
);
});
+104
View File
@@ -0,0 +1,104 @@
import {
AI_REVIEW_SUBJECT,
buildWhatsAppUrl,
isServiceOrCasePath,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "./whatsapp";
const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" },
cases: { "sev-modaintima": "SEV Moda Íntima" },
};
describe("whatsAppSubjectForPath", () => {
it("cites the service on /servicos/<slug>", () => {
expect(whatsAppSubjectForPath("/servicos/criacao-de-sites", contexts)).toBe(
"Sites profissionais",
);
});
it("cites the case on /cases/<slug>", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima", contexts)).toBe(
"um projeto parecido com o da SEV Moda Íntima",
);
});
it("cites the AI review on /ia", () => {
expect(whatsAppSubjectForPath("/ia", contexts)).toBe(AI_REVIEW_SUBJECT);
expect(whatsAppSubjectForPath("/ia/", undefined)).toBe(AI_REVIEW_SUBJECT);
});
it("ignores a trailing slash", () => {
expect(
whatsAppSubjectForPath("/servicos/criacao-de-sites/", contexts),
).toBe("Sites profissionais");
});
it.each([
"/",
"/servicos",
"/cases",
"/curriculo",
"/privacidade",
"/servicos/inexistente",
"/cases/inexistente",
"/servicos/criacao-de-sites/extra",
// nomes que existem em todo objeto não podem virar assunto
"/servicos/constructor",
"/cases/__proto__",
"/cases/toString",
])("falls back to a generic message on %s", (pathname) => {
expect(whatsAppSubjectForPath(pathname, contexts)).toBeUndefined();
});
it("treats an empty or blank name as missing, so the message never reads 'sobre .'", () => {
const blank: WhatsAppContexts = {
services: { vazio: "", espacos: " " },
cases: { vazio: "", espacos: " " },
};
for (const pathname of [
"/servicos/vazio",
"/servicos/espacos",
"/cases/vazio",
"/cases/espacos",
]) {
expect(whatsAppSubjectForPath(pathname, blank)).toBeUndefined();
}
});
it("is generic without contexts, without a pathname or outside the router", () => {
expect(
whatsAppSubjectForPath("/servicos/criacao-de-sites"),
).toBeUndefined();
expect(whatsAppSubjectForPath(null, contexts)).toBeUndefined();
expect(whatsAppSubjectForPath(undefined, contexts)).toBeUndefined();
});
});
describe("isServiceOrCasePath", () => {
it.each([
["/servicos/criacao-de-sites", true],
["/servicos/qualquer-coisa/", true],
["/cases/sev-modaintima", true],
["/", false],
["/servicos", false],
["/cases", false],
["/ia", false],
["/servicos/a/b", false],
[null, false],
[undefined, false],
])("%s -> %s", (pathname, expected) => {
expect(isServiceOrCasePath(pathname)).toBe(expected);
});
});
describe("buildWhatsAppUrl", () => {
it("encodes the message in the text parameter of a wa.me link", () => {
const url = new URL(buildWhatsAppUrl("Olá & tudo? #1"));
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d{12,13}$/);
expect(url.searchParams.get("text")).toBe("Olá & tudo? #1");
});
});
+44
View File
@@ -15,3 +15,47 @@ export function getWhatsAppDisplayNumber() {
export function buildWhatsAppUrl(message: string) { export function buildWhatsAppUrl(message: string) {
return `https://wa.me/${getWhatsAppNumber()}?text=${encodeURIComponent(message)}`; return `https://wa.me/${getWhatsAppNumber()}?text=${encodeURIComponent(message)}`;
} }
/**
* Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts:
* esse arquivo reúne o texto de todas as páginas e não deve ir inteiro para o navegador.
*/
export interface WhatsAppContexts {
/** slug do serviço -> nome do serviço */
services: Record<string, string>;
/** id do case -> nome do projeto ou do cliente */
cases: Record<string, string>;
}
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem. */
export const AI_REVIEW_SUBJECT = "a revisão de um aplicativo feito com IA";
/** Nome pela chave; nome vazio ou só com espaços vale como ausente, para a mensagem cair no assunto padrão em vez de "sobre .". */
function lookup(table: Record<string, string> | undefined, key: string) {
const name = table && Object.hasOwn(table, key) ? table[key].trim() : "";
return name || undefined;
}
/**
* Assunto da conversa de WhatsApp para a rota atual. Em /servicos/<slug> cita o serviço, em /cases/<slug> o case
* e em /ia a revisão de aplicativo; nas demais rotas devolve undefined e a mensagem fala de "um projeto".
*/
export function whatsAppSubjectForPath(
pathname: string | null | undefined,
contexts?: WhatsAppContexts,
): string | undefined {
const path = pathname?.replace(/\/$/, "") ?? "";
if (path === "/ia") return AI_REVIEW_SUBJECT;
const slug = path.match(/^\/servicos\/([^/]+)$/)?.[1];
if (slug) return lookup(contexts?.services, slug);
const caseId = path.match(/^\/cases\/([^/]+)$/)?.[1];
const title = caseId ? lookup(contexts?.cases, caseId) : undefined;
return title ? `um projeto parecido com o da ${title}` : undefined;
}
/** Páginas de serviço e de case: nelas o botão do cabeçalho abre o WhatsApp em vez de ir ao formulário da home. */
export function isServiceOrCasePath(pathname: string | null | undefined) {
return /^\/(servicos|cases)\/[^/]+\/?$/.test(pathname ?? "");
}
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// não anuncia "X-Powered-By: Next.js" em cada resposta
poweredByHeader: false,
// Otimização de imagens // Otimização de imagens
images: { images: {
formats: ["image/webp", "image/avif"], formats: ["image/webp", "image/avif"],
Binary file not shown.
Binary file not shown.