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
+3099 -906

No files matched your search

+81 -108
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 />);
expect( const cards = [...container.querySelectorAll("article")];
screen.getByRole("heading", { expect(cards).toHaveLength(4);
level: 3, for (const card of cards.slice(0, 3)) {
name: "Do sinal físico ao produto digital", expect(card).toHaveClass("cv-press", "cv-lift");
}), }
).toBeInTheDocument(); expect(cards[3]).toHaveClass("cv-press");
expect(screen.getByText("Arquitetura pragmática")).toBeInTheDocument(); });
expect(
screen.getByRole("link", { name: "Ver trajetória completa" }), it("path: the story, the profile points and the résumé link are all there", () => {
).toHaveAttribute("href", "/curriculo"); render(<About />);
unmount();
expect(
screen.getByRole("heading", {
level: 3,
name: "Você fala direto com quem desenvolve",
}),
).toBeInTheDocument();
expect(
screen.getByText("Solução do tamanho do seu problema"),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver trajetória completa" }),
).toHaveAttribute("href", "/curriculo");
});
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");
} }
}); });
}); });
+93 -124
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,85 +58,89 @@ 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 }}
> >
<div className="ds-kicker">{step.label}</div> <article className="ds-card cv-press cv-lift flex-1">
<DrawLine delay={0.03} /> <div className="ds-kicker">{step.label}</div>
<h3 className="ds-card-title">{step.title}</h3> <DrawLine delay={0.03} />
<p className="ds-copy" data-reel="right"> <h3 className="ds-card-title">{step.title}</h3>
{step.content} <p className="ds-copy" data-reel="right">
</p> {step.content}
</motion.article> </p>
</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 }}
> >
<LazyRevealImage <article className="ds-card cv-press cv-lift h-full">
src={profilePhoto} <div className="flex items-center gap-sp-5">
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC" <LazyRevealImage
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]" src={profilePhoto}
imageClassName="object-contain" alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
/> className="aspect-square w-28 shrink-0 overflow-hidden rounded-[6px] border border-[var(--border)] sm:w-32"
<div className="ds-kicker">Da arquitetura à implementação</div> imageClassName="object-cover"
<DrawLine delay={0.05} /> sizes="128px"
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3> />
<p className="ds-copy" data-reel="top"> <div className="min-w-0">
Meu trabalho conecta interface, serviços, dados e, quando <div className="ds-kicker">Da ideia ao sistema no ar</div>
necessário, equipamentos. Entender essas partes em conjunto ajuda a <h3 className="ds-card-title !mt-sp-2">
definir o que construir, o que integrar e o que pode ser Uma pessoa cuida do sistema inteiro
aproveitado. </h3>
</p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<div className="text-[12px] font-medium tracking-[0.02em]">
{point.title}
</div>
<p className="ds-copy !mt-sp-2">{point.description}</p>
</div> </div>
))} </div>
</div> <DrawLine delay={0.05} />
<Link <p className="ds-copy" data-reel="top">
href="/curriculo" Cuido da tela que sua equipe usa, dos dados por trás dela e,
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`} quando o negócio tem máquinas ou sensores, da ligação deles com o
> sistema. Ver tudo junto ajuda a decidir o que construir, o que
Ver trajetória completa conectar e o que dá para aproveitar.
</Link> </p>
</motion.article> <p className="ds-copy">
</motion.div> Desde 2022 desenvolvo sistemas internos, aplicativos e integrações
IoT na Apolo Energia.
</p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<div className="text-[15px] font-medium tracking-[0.02em]">
{point.title}
</div>
<p className="ds-copy !mt-sp-2">{point.description}</p>
</div>
))}
</div>
<Link
href="/curriculo"
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
>
Ver trajetória completa
</Link>
</article>
</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();
});
+43 -24
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()}
> >
<p>Já entregue para:</p> {clients.length > 0 && (
<ul className="flex flex-wrap items-baseline gap-x-sp-3"> <>
{clients.map((client, index) => ( <p>Já entregue para:</p>
<li key={client.id} className="flex items-baseline gap-x-sp-3"> <ul className="flex flex-wrap items-center gap-x-sp-3">
<Link {clients.map((client, index) => (
href={`/cases/${client.id}`} <li key={client.id} className="flex items-center gap-x-sp-3">
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)]" <HoverPrefetchLink
> href={`/cases/${client.id}`}
{client.name} 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)]"
</Link> >
{/* o ponto fica no fim do item: numa quebra de linha ele nunca abre a linha seguinte */} {client.name}
{index < clients.length - 1 && ( </HoverPrefetchLink>
<span aria-hidden="true" className="text-[rgba(244,242,237,.4)]"> {/* 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 || Boolean(years)) && (
</span> <span
)} aria-hidden="true"
</li> className="text-[rgba(244,242,237,.4)]"
))} >
</ul> ·
</span>
)}
</li>
))}
</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>"),
);
});
});
+219 -126
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 <input
</label> {...control}
<input name="name"
id="contact-name" required
name="name" type="text"
required autoComplete="name"
type="text" placeholder="Como posso te chamar?"
autoComplete="name" value={formData.name}
placeholder="Como posso te chamar?" onChange={(e) => updateField("name", e.target.value)}
aria-invalid={errors.name ? true : undefined} className={fieldClassName}
aria-describedby={ />
errors.name ? "contact-name-error" : undefined
}
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
className={fieldClassName}
/>
{errors.name && (
<p id="contact-name-error" className={errorClassName}>
{errors.name}
</p>
)} )}
</div> </Field>
<div> <Field
<label htmlFor="contact-email" className={labelClassName}> id="contact-email"
E-mail (opcional) label="E-mail (opcional)"
</label> error={errors.email}
<input
id="contact-email"
name="email"
type="email"
autoComplete="email"
placeholder="[email protected]"
aria-invalid={errors.email ? true : undefined}
aria-describedby={
errors.email ? "contact-email-error" : undefined
}
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
className={fieldClassName}
/>
{errors.email && (
<p id="contact-email-error" className={errorClassName}>
{errors.email}
</p>
)}
</div>
</div>
<div>
<label htmlFor="contact-subject" className={labelClassName}>
Tipo de projeto
</label>
<select
id="contact-subject"
name="subject"
value={formData.subject}
onChange={(e) => updateField("subject", e.target.value)}
className={`${fieldClassName} [color-scheme:dark]`}
> >
<option value="Site para empresa">Site para empresa</option> {(control) => (
<option value="Sistemas web sob medida"> <input
Sistemas web sob medida {...control}
</option> name="email"
<option value="Aplicativos">Aplicativos</option> type="email"
<option value="Integrações e automações"> autoComplete="email"
Integrações e automações placeholder="[email protected]"
</option> value={formData.email}
<option value="Evolução de um sistema existente"> onChange={(e) => updateField("email", e.target.value)}
Evolução de um sistema existente className={fieldClassName}
</option> />
<option value="Ainda preciso definir"> )}
Ainda preciso definir </Field>
</option>
</select>
</div> </div>
<div> <Field id="contact-subject" label="Tipo de projeto">
<label htmlFor="contact-message" className={labelClassName}> {(control) => (
Contexto do projeto <select
</label> {...control}
<textarea name="subject"
id="contact-message" value={formData.subject}
name="message" onChange={(e) => updateField("subject", e.target.value)}
required className={`${fieldClassName} [color-scheme:dark]`}
rows={4} >
autoComplete="off" {SUBJECT_OPTIONS.map((option) => (
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?" <option key={option} value={option}>
aria-invalid={errors.message ? true : undefined} {option}
aria-describedby={ </option>
errors.message ))}
? "contact-message-error contact-privacy-note" </select>
: "contact-privacy-note"
}
value={formData.message}
onChange={(e) => updateField("message", e.target.value)}
className={`${fieldClassName} min-h-32 resize-y`}
/>
{errors.message && (
<p id="contact-message-error" className={errorClassName}>
{errors.message}
</p>
)} )}
</div> </Field>
<Field
id="contact-message"
label="Contexto do projeto"
error={errors.message}
describedBy="contact-privacy-note"
>
{(control) => (
<textarea
{...control}
name="message"
required
rows={4}
autoComplete="off"
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
value={formData.message}
onChange={(e) => updateField("message", e.target.value)}
className={`${fieldClassName} min-h-32 resize-y`}
/>
)}
</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)
+127 -119
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) {
const cards = seen("article"); expect(element.style.opacity).toBe("");
expect(cards).toHaveLength(3); expect(element.style.transform).toBe("");
for (const card of cards) {
expect(card.whileHover).toEqual({
y: -4,
boxShadow: "0 24px 50px rgba(0, 0, 0, 0.08)",
});
expect(card.whileTap).toEqual({ scale: 0.99 });
} }
}); });
it("path: reduced motion -> tiers only fade, with no hover lift and no press feedback", () => { it("path: the cards are not focus stops and keep no outline-none (touch and hover feedback come from CSS)", () => {
jest.mocked(useReducedMotion).mockReturnValue(true); const { container } = render(<WorkModalities />);
render(<WorkModalities />); const cards = container.querySelectorAll("article");
expect(cards).toHaveLength(3);
const items = seen("div").filter( for (const card of cards) {
(props) => props.variants?.hidden?.opacity === 0, expect(card).not.toHaveAttribute("tabindex");
); expect(card.className).not.toContain("outline-none");
expect(items).toHaveLength(3); expect(card).toHaveClass("cv-press", "cv-lift");
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(); it("path: each tier sits alone in its own reveal wrapper, which can shrink in the grid", () => {
const { container } = render(<WorkModalities />);
for (const card of container.querySelectorAll("article")) {
const wrapper = card.closest(".ds-reveal");
expect(wrapper).toHaveClass("min-w-0");
expect(wrapper?.querySelectorAll("article")).toHaveLength(1);
} }
}); });
}); });
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/);
}); });
}); });
+45 -74
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)}
{TIERS.map((tier) => ( className="min-w-0"
<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();
}); });
+115 -46
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,40 +224,56 @@ 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. */}
O que casos documentados ensinam <details className="group border-y border-[var(--border)]">
</h2> <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">
<p className="ds-copy max-w-[75ch]"> <h2
São episódios de 2025, não avaliações atuais das plataformas. Eles id="ia-casos-title"
mostram riscos de configuração, permissões e operação; não provam className="font-display text-[clamp(20px,3vw,28px)] font-semibold leading-tight tracking-[-0.02em]"
que todo sistema feito com IA seja inseguro ou que um profissional >
elimine qualquer risco. O que casos documentados ensinam
</p> </h2>
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3"> <span
{INCIDENTS.map((incident) => ( aria-hidden="true"
<article key={incident.title} className="ds-card"> className="shrink-0 font-mono text-2xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
<h3 className="ds-card-title !mt-0">{incident.title}</h3> >
<p className="ds-copy">{incident.summary}</p> +
<p className="ds-copy"> </span>
<strong>O cuidado técnico:</strong> {incident.care} </summary>
</p> <div className="pb-sp-8">
<ul className="mt-sp-4 flex flex-col gap-sp-1"> <p className="ds-copy max-w-[75ch]">
{incident.links.map((link) => ( São episódios de 2025, não avaliações atuais das plataformas.
<li key={link.href}> Eles mostram riscos de configuração, permissões e operação; não
<a provam que todo sistema feito com IA seja inseguro ou que um
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4" profissional elimine qualquer risco.
href={link.href} </p>
target="_blank" <div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
rel="noopener noreferrer" {INCIDENTS.map((incident) => (
> <article key={incident.title} className="ds-card">
{link.label} <h3 className="ds-card-title !mt-0">{incident.title}</h3>
</a> <p className="ds-copy">{incident.summary}</p>
</li> <p className="ds-copy">
))} <strong>O cuidado técnico:</strong> {incident.care}
</ul> </p>
</article> <ul className="mt-sp-4 flex flex-col gap-sp-1">
))} {incident.links.map((link) => (
</div> <li key={link.href}>
<a
className="inline-flex min-h-11 items-center text-[15px] md:text-sm underline underline-offset-4"
href={link.href}
target="_blank"
rel="noopener noreferrer"
>
{link.label}
</a>
</li>
))}
</ul>
</article>
))}
</div>
</div>
</details>
</div> </div>
</section> </section>
@@ -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.