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:
1 parent
e80f73d075
commit
10f170abf4
56 files changed
+3099
-906
No files matched your search
@@ -1,53 +1,12 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import About from "./About";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de About: todas as decisões dependem de `shouldReduceMotion`
|
||||
* (variantes dos cards, transição, hover e toque), então há dois caminhos: movimento normal e reduzido.
|
||||
* O `motion/react` é trocado por elementos simples que registram as props de animação.
|
||||
* Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o
|
||||
* feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de
|
||||
* 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", () => ({
|
||||
__esModule: true,
|
||||
default: { src: "/edev.jpg", width: 10, height: 10 },
|
||||
@@ -58,13 +17,16 @@ jest.mock("./Section", () => ({
|
||||
children,
|
||||
id,
|
||||
title,
|
||||
aside,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
id?: string;
|
||||
title?: string;
|
||||
aside?: React.ReactNode;
|
||||
}) => (
|
||||
<section id={id}>
|
||||
<h2>{title}</h2>
|
||||
{aside}
|
||||
{children}
|
||||
</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", () => {
|
||||
it("path: normal motion -> cards rise and clip in, with hover lift and press feedback", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(false);
|
||||
it("path: the four cards enter through Reveal -> same rise and duration, delays growing in steps", () => {
|
||||
const { container } = render(<About />);
|
||||
|
||||
render(<About />);
|
||||
|
||||
const cards = renderedCards();
|
||||
expect(cards).toHaveLength(4); // 3 etapas da história + o card do perfil
|
||||
for (const card of cards) {
|
||||
expect(card.variants?.hidden).toEqual({
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
clipPath: "inset(100% 0 0 0)",
|
||||
});
|
||||
expect(card.variants?.visible.transition).toEqual({
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
});
|
||||
expect(card.whileHover).toEqual({
|
||||
y: -4,
|
||||
boxShadow: "0 18px 40px rgba(10,10,10,0.16)",
|
||||
});
|
||||
expect(card.whileTap).toEqual({ scale: 0.99 });
|
||||
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)
|
||||
expect(reveals).toHaveLength(4);
|
||||
expect(
|
||||
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
||||
).toEqual(["20px", "20px", "20px", "20px"]);
|
||||
expect(
|
||||
reveals.map((element) =>
|
||||
element.style.getPropertyValue("--reveal-duration"),
|
||||
),
|
||||
).toEqual(["0.8s", "0.8s", "0.8s", "0.8s"]);
|
||||
const delays = reveals.map((element) =>
|
||||
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
|
||||
);
|
||||
expect(delays).toEqual([0.08, 0.16, 0.24, 0.32]);
|
||||
for (const element of reveals) {
|
||||
expect(element.style.opacity).toBe("");
|
||||
expect(element.style.transform).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("path: reduced motion -> cards only fade, with no hover lift and no press feedback", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(true);
|
||||
it("path: the four cards are not focus stops and use the CSS press feedback", () => {
|
||||
const { container } = render(<About />);
|
||||
|
||||
render(<About />);
|
||||
|
||||
const cards = renderedCards();
|
||||
const cards = container.querySelectorAll("article");
|
||||
expect(cards).toHaveLength(4);
|
||||
for (const card of cards) {
|
||||
expect(card.variants?.hidden).toEqual({ opacity: 0 });
|
||||
expect(card.variants?.visible.transition).toEqual({ duration: 0.25 });
|
||||
expect(card.whileHover).toBeUndefined();
|
||||
expect(card.whileTap).toBeUndefined();
|
||||
expect(card).not.toHaveAttribute("tabindex");
|
||||
expect(card).toHaveClass("cv-press");
|
||||
}
|
||||
});
|
||||
|
||||
it("path: content does not depend on motion -> same story, profile points and résumé link", () => {
|
||||
for (const reduced of [false, true]) {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(reduced);
|
||||
const { unmount } = render(<About />);
|
||||
it("path: the three story cards lift on hover and press with CSS; the profile card presses only", () => {
|
||||
const { container } = render(<About />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Do sinal físico ao produto digital",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Arquitetura pragmática")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver trajetória completa" }),
|
||||
).toHaveAttribute("href", "/curriculo");
|
||||
unmount();
|
||||
const cards = [...container.querySelectorAll("article")];
|
||||
expect(cards).toHaveLength(4);
|
||||
for (const card of cards.slice(0, 3)) {
|
||||
expect(card).toHaveClass("cv-press", "cv-lift");
|
||||
}
|
||||
expect(cards[3]).toHaveClass("cv-press");
|
||||
});
|
||||
|
||||
it("path: the story, the profile points and the résumé link are all there", () => {
|
||||
render(<About />);
|
||||
|
||||
expect(
|
||||
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
@@ -1,90 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import profilePhoto from "@/public/edev.jpg";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||
import { textLink } from "./button-styles";
|
||||
import "./conversion.css";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { Section } from "./Section";
|
||||
|
||||
const STORY_DATA = [
|
||||
{
|
||||
label: "Hardware + software",
|
||||
title: "Do sinal físico ao produto digital",
|
||||
label: "Como eu trabalho",
|
||||
title: "Você fala direto com quem desenvolve",
|
||||
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",
|
||||
title: "Entrega clara mesmo sob restrição",
|
||||
label: "Sem começar do zero",
|
||||
title: "Aproveito o que você já tem",
|
||||
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",
|
||||
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 = [
|
||||
{
|
||||
title: "Arquitetura pragmática",
|
||||
title: "Solução do tamanho do seu problema",
|
||||
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:
|
||||
"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:
|
||||
"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() {
|
||||
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 (
|
||||
<Section
|
||||
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."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric" data-reel="left">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
|
||||
Base em Garuva, SC | Atendimento remoto
|
||||
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-slash" />
|
||||
Garuva, SC · Atendimento remoto
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className="ds-grid-2 items-start"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
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
|
||||
<div className="ds-grid-2 items-stretch">
|
||||
<div className="flex flex-col gap-sp-6">
|
||||
{STORY_DATA.map((step, index) => (
|
||||
<Reveal
|
||||
key={step.title}
|
||||
className="ds-card"
|
||||
variants={cardVariants}
|
||||
whileHover={cardHover}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
y={20}
|
||||
duration={0.8}
|
||||
delay={cardDelay(index)}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
<div className="ds-kicker">{step.label}</div>
|
||||
<DrawLine delay={0.03} />
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{step.content}
|
||||
</p>
|
||||
</motion.article>
|
||||
<article className="ds-card cv-press cv-lift flex-1">
|
||||
<div className="ds-kicker">{step.label}</div>
|
||||
<DrawLine delay={0.03} />
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{step.content}
|
||||
</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.article
|
||||
className="ds-card h-full"
|
||||
variants={cardVariants}
|
||||
whileHover={cardHover}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
<Reveal
|
||||
y={20}
|
||||
duration={0.8}
|
||||
delay={cardDelay(STORY_DATA.length)}
|
||||
className="h-full"
|
||||
>
|
||||
<LazyRevealImage
|
||||
src={profilePhoto}
|
||||
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
|
||||
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||
imageClassName="object-contain"
|
||||
/>
|
||||
<div className="ds-kicker">Da arquitetura à implementação</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">Uma visão do sistema inteiro</h3>
|
||||
<p className="ds-copy" data-reel="top">
|
||||
Meu trabalho conecta interface, serviços, dados e, quando
|
||||
necessário, equipamentos. Entender essas partes em conjunto ajuda a
|
||||
definir o que construir, o que integrar e o que pode ser
|
||||
aproveitado.
|
||||
</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>
|
||||
<article className="ds-card cv-press cv-lift h-full">
|
||||
<div className="flex items-center gap-sp-5">
|
||||
<LazyRevealImage
|
||||
src={profilePhoto}
|
||||
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"
|
||||
imageClassName="object-cover"
|
||||
sizes="128px"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="ds-kicker">Da ideia ao sistema no ar</div>
|
||||
<h3 className="ds-card-title !mt-sp-2">
|
||||
Uma pessoa cuida do sistema inteiro
|
||||
</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/curriculo"
|
||||
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
|
||||
>
|
||||
Ver trajetória completa
|
||||
</Link>
|
||||
</motion.article>
|
||||
</motion.div>
|
||||
</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<p className="ds-copy" data-reel="top">
|
||||
Cuido da tela que sua equipe usa, dos dados por trás dela e,
|
||||
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
|
||||
conectar e o que dá para aproveitar.
|
||||
</p>
|
||||
<p className="ds-copy">
|
||||
Desde 2022 desenvolvo sistemas internos, aplicativos e integrações
|
||||
IoT na Apolo Energia.
|
||||
</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -29,3 +29,53 @@ it("renders nothing without clients", () => {
|
||||
const withoutProp = render(<ClientStrip />);
|
||||
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();
|
||||
});
|
||||
@@ -1,43 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||
|
||||
export type ClientLink = { id: string; name: string };
|
||||
|
||||
/**
|
||||
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case.
|
||||
* Sem clientes, nada é desenhado.
|
||||
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
|
||||
* 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({
|
||||
clients = [],
|
||||
years,
|
||||
className = "",
|
||||
}: {
|
||||
clients?: ClientLink[];
|
||||
/** Anos de experiência, na mesma linha dos clientes. */
|
||||
years?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (clients.length === 0) return null;
|
||||
if (clients.length === 0 && !years) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-baseline gap-x-sp-3 gap-y-sp-1 font-display text-[14px] leading-6 text-[rgba(244,242,237,.64)] sm:text-[15px] ${className}`.trim()}
|
||||
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>
|
||||
<ul className="flex flex-wrap items-baseline gap-x-sp-3">
|
||||
{clients.map((client, index) => (
|
||||
<li key={client.id} className="flex items-baseline gap-x-sp-3">
|
||||
<Link
|
||||
href={`/cases/${client.id}`}
|
||||
className="font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
{client.name}
|
||||
</Link>
|
||||
{/* o ponto fica no fim do item: numa quebra de linha ele nunca abre a linha seguinte */}
|
||||
{index < clients.length - 1 && (
|
||||
<span aria-hidden="true" className="text-[rgba(244,242,237,.4)]">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{clients.length > 0 && (
|
||||
<>
|
||||
<p>Já entregue para:</p>
|
||||
<ul className="flex flex-wrap items-center gap-x-sp-3">
|
||||
{clients.map((client, index) => (
|
||||
<li key={client.id} className="flex items-center gap-x-sp-3">
|
||||
<HoverPrefetchLink
|
||||
href={`/cases/${client.id}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
{client.name}
|
||||
</HoverPrefetchLink>
|
||||
{/* 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
|
||||
aria-hidden="true"
|
||||
className="text-[rgba(244,242,237,.4)]"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{years ? (
|
||||
<p>
|
||||
<span className="font-mono font-semibold text-[var(--slash)]">
|
||||
{years}
|
||||
</span>{" "}
|
||||
anos de experiência
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,10 @@ beforeEach(() => {
|
||||
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||
delete window["ga-disable-G-QTT9QT0T3S"];
|
||||
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(() => {
|
||||
@@ -48,7 +51,7 @@ function sentMessage() {
|
||||
|
||||
describe("Contact: validate()", () => {
|
||||
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 />);
|
||||
await fill(user, {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await fill(user, {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await fill(user, { name: "Ana" });
|
||||
|
||||
@@ -105,7 +108,7 @@ describe("Contact: validate()", () => {
|
||||
});
|
||||
|
||||
it("path: name and message made only of spaces -> both rejected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await fill(user, {
|
||||
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"])(
|
||||
"path: e-mail %j -> invalid format is blocked",
|
||||
async (email) => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
|
||||
|
||||
@@ -150,7 +153,7 @@ describe("Contact: validate()", () => {
|
||||
it.each(["[email protected]", "[email protected]"])(
|
||||
"path: e-mail %j -> valid format is accepted and sent",
|
||||
async (email) => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await fill(user, { name: "Ana", email, message: "Organizar pedidos" });
|
||||
|
||||
@@ -165,7 +168,7 @@ describe("Contact: validate()", () => {
|
||||
|
||||
describe("Contact: updateField() and the error state", () => {
|
||||
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 />);
|
||||
await user.click(submitButton());
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await user.click(submitButton());
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
@@ -214,7 +217,7 @@ describe("Contact: updateField() and the error state", () => {
|
||||
|
||||
describe("Contact: mensagem montada", () => {
|
||||
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 />);
|
||||
await fill(user, {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
await user.selectOptions(
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<Contact />);
|
||||
|
||||
const direct = screen.getByRole("link", {
|
||||
|
||||
+242
-17
@@ -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 { renderToStaticMarkup } from "react-dom/server";
|
||||
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(() => {
|
||||
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
||||
localStorage.setItem("evandro.analytics-consent.v1", "granted");
|
||||
delete window["ga-disable-G-QTT9QT0T3S"];
|
||||
window.gtag = jest.fn();
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
openedWindow = { opener: {} };
|
||||
jest
|
||||
.spyOn(window, "open")
|
||||
.mockImplementation(() => openedWindow as unknown as Window);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -17,7 +30,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
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);
|
||||
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
|
||||
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.gtag).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
|
||||
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
const [destination, target, features] = jest.mocked(window.open).mock
|
||||
.calls[0];
|
||||
const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0];
|
||||
const url = new URL(String(destination));
|
||||
expect(url.origin).toBe("https://wa.me");
|
||||
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(context);
|
||||
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).toHaveBeenCalledWith("event", "whatsapp_open", {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
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 () => {
|
||||
localStorage.setItem("evandro.analytics-consent.v1", "denied");
|
||||
delete window.gtag;
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
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 message = url.searchParams.get("text") ?? "";
|
||||
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:");
|
||||
});
|
||||
|
||||
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 />);
|
||||
|
||||
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
|
||||
const options = Array.from(subject.querySelectorAll("option")).map(
|
||||
(option) => option.value,
|
||||
);
|
||||
expect(options[0]).toBe("Site para empresa");
|
||||
expect(subject).toHaveValue("Site para empresa");
|
||||
expect(options[0]).toBe("Ainda preciso definir");
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const user = setup();
|
||||
render(<Contact />);
|
||||
|
||||
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") ?? "";
|
||||
expect(body).toContain("Nome: Ana");
|
||||
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);
|
||||
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).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
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
|
||||
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)}`;
|
||||
}
|
||||
|
||||
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() {
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
useEffect(() => {
|
||||
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({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "Site para empresa",
|
||||
subject: "Ainda preciso definir",
|
||||
message: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
@@ -77,22 +173,31 @@ export default function Contact() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
/** Mostra os erros, leva o foco ao primeiro campo inválido e diz se há algum. */
|
||||
const hasInvalidField = (form: HTMLFormElement | null) => {
|
||||
const found = validate(formData);
|
||||
setErrors(found);
|
||||
const firstInvalid = FIELD_ORDER.find((field) => found[field]);
|
||||
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;
|
||||
}
|
||||
window.open(
|
||||
buildWhatsAppUrl(buildProjectMessage(formData)),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
|
||||
trackEmailClick({ placement: "contact_form" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,7 +224,7 @@ export default function Contact() {
|
||||
<h3 className="font-display text-lg font-semibold text-white">
|
||||
Você fala direto com quem desenvolve.
|
||||
</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;
|
||||
escopo, prazo e proposta vêm depois.
|
||||
</p>
|
||||
@@ -133,10 +238,10 @@ export default function Contact() {
|
||||
<WhatsAppMark className="h-6 w-6" />
|
||||
</span>
|
||||
<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?
|
||||
</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}
|
||||
</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)]"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
@@ -160,7 +265,7 @@ export default function Contact() {
|
||||
/>
|
||||
</a>
|
||||
</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" />
|
||||
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
|
||||
</p>
|
||||
@@ -183,117 +288,85 @@ export default function Contact() {
|
||||
>
|
||||
Prepare sua mensagem
|
||||
</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.
|
||||
</p>
|
||||
</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>
|
||||
<label htmlFor="contact-name" className={labelClassName}>
|
||||
Nome completo
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Como posso te chamar?"
|
||||
aria-invalid={errors.name ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.name ? "contact-name-error" : undefined
|
||||
}
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="contact-name-error" className={errorClassName}>
|
||||
{errors.name}
|
||||
</p>
|
||||
<Field id="contact-name" label="Seu nome" error={errors.name}>
|
||||
{(control) => (
|
||||
<input
|
||||
{...control}
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Como posso te chamar?"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-email" className={labelClassName}>
|
||||
E-mail (opcional)
|
||||
</label>
|
||||
<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]`}
|
||||
</Field>
|
||||
<Field
|
||||
id="contact-email"
|
||||
label="E-mail (opcional)"
|
||||
error={errors.email}
|
||||
>
|
||||
<option value="Site para empresa">Site para empresa</option>
|
||||
<option value="Sistemas web sob medida">
|
||||
Sistemas web sob medida
|
||||
</option>
|
||||
<option value="Aplicativos">Aplicativos</option>
|
||||
<option value="Integrações e automações">
|
||||
Integrações e automações
|
||||
</option>
|
||||
<option value="Evolução de um sistema existente">
|
||||
Evolução de um sistema existente
|
||||
</option>
|
||||
<option value="Ainda preciso definir">
|
||||
Ainda preciso definir
|
||||
</option>
|
||||
</select>
|
||||
{(control) => (
|
||||
<input
|
||||
{...control}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="[email protected]"
|
||||
value={formData.email}
|
||||
onChange={(e) => updateField("email", e.target.value)}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-message" className={labelClassName}>
|
||||
Contexto do projeto
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
required
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
|
||||
aria-invalid={errors.message ? true : undefined}
|
||||
aria-describedby={
|
||||
errors.message
|
||||
? "contact-message-error contact-privacy-note"
|
||||
: "contact-privacy-note"
|
||||
}
|
||||
value={formData.message}
|
||||
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>
|
||||
<Field id="contact-subject" label="Tipo de projeto">
|
||||
{(control) => (
|
||||
<select
|
||||
{...control}
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) => updateField("subject", e.target.value)}
|
||||
className={`${fieldClassName} [color-scheme:dark]`}
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</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
|
||||
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"
|
||||
@@ -305,7 +378,7 @@ export default function Contact() {
|
||||
<p className="text-center font-display text-[15px] leading-6 text-white/70">
|
||||
<a
|
||||
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)]"
|
||||
>
|
||||
Prefere e-mail? Enviar por e-mail
|
||||
@@ -313,13 +386,33 @@ export default function Contact() {
|
||||
</p>
|
||||
<p
|
||||
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
|
||||
para você revisar. Não inclua senhas nem dados pessoais de
|
||||
clientes.
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -25,12 +25,14 @@ it("is the short home version and points to /ia for the full content", () => {
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Já começou com IA? Dá para partir daí.",
|
||||
name: "Fez um app com IA? Eu reviso.",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Entender como funciona" }),
|
||||
screen.getByRole("link", { name: "Ver a revisão de app feito com 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
|
||||
expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull();
|
||||
});
|
||||
@@ -2,18 +2,18 @@ import Link from "next/link";
|
||||
import { secondaryButtonLight } from "./button-styles";
|
||||
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() {
|
||||
return (
|
||||
<Section
|
||||
id="engineering-ai"
|
||||
eyebrow="IA no desenvolvimento"
|
||||
title="Já começou com IA? Dá para partir daí."
|
||||
description="Seu protótipo ajuda a explicar a ideia. Antes de propor uma reconstrução, avalio o que pode ser aproveitado, quais riscos precisam de correção e o que falta para atender usuários em produção."
|
||||
title="Fez um app com IA? Eu reviso."
|
||||
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
|
||||
>
|
||||
<Link href="/ia" className={secondaryButtonLight}>
|
||||
Entender como funciona
|
||||
Ver a revisão de app feito com IA
|
||||
</Link>
|
||||
</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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
|
||||
import WhatsAppLink from "./WhatsAppLink";
|
||||
import { WhatsAppMark } from "./WhatsAppMark";
|
||||
|
||||
@@ -9,17 +11,28 @@ export const HERO_CTA_ID = "hero-contact-cta";
|
||||
const CONTACT_SECTION_ID = "contact";
|
||||
|
||||
/**
|
||||
* Atalho para o WhatsApp só no celular (abaixo de 768 px). Fica escondido quando já há um convite de contato
|
||||
* à vista e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
|
||||
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
|
||||
* 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
|
||||
const [inviteVisible, setInviteVisible] = useState<Record<
|
||||
string,
|
||||
boolean
|
||||
> | 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(() => {
|
||||
setInviteVisible(null);
|
||||
const targets = [HERO_CTA_ID, CONTACT_SECTION_ID]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((element): element is HTMLElement => element !== null);
|
||||
@@ -39,7 +52,7 @@ export default function FloatingWhatsApp() {
|
||||
});
|
||||
for (const target of targets) observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [pathname]);
|
||||
|
||||
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">
|
||||
<WhatsAppLink
|
||||
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"
|
||||
>
|
||||
<WhatsAppMark className="h-7 w-7" />
|
||||
|
||||
@@ -22,6 +22,16 @@ describe("Footer contact and navigation", () => {
|
||||
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", () => {
|
||||
render(<Footer serviceLinks={serviceHubLinks} />);
|
||||
|
||||
@@ -45,6 +55,9 @@ describe("Footer contact and navigation", () => {
|
||||
"href",
|
||||
"/cases",
|
||||
);
|
||||
expect(
|
||||
within(more).getByRole("link", { name: "Revisão de app feito com IA" }),
|
||||
).toHaveAttribute("href", "/ia");
|
||||
expect(
|
||||
within(more).getByRole("link", { name: "Privacidade" }),
|
||||
).toHaveAttribute("href", "/privacidade");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/lib/site-navigation";
|
||||
import "./conversion.css";
|
||||
import { Logo } from "./Logo";
|
||||
import WhatsAppLink from "./WhatsAppLink";
|
||||
|
||||
const headingClassName =
|
||||
"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();
|
||||
|
||||
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="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10">
|
||||
<section aria-labelledby="footer-contact-title">
|
||||
@@ -38,6 +40,14 @@ export default function Footer({
|
||||
Contato
|
||||
</p>
|
||||
<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>
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
@@ -100,6 +110,11 @@ export default function Footer({
|
||||
Cases
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/ia" className={linkClassName}>
|
||||
Revisão de app feito com IA
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/privacidade" className={linkClassName}>
|
||||
Privacidade
|
||||
@@ -117,7 +132,7 @@ export default function Footer({
|
||||
<li>
|
||||
<Link
|
||||
href="/curriculo"
|
||||
className={`${linkClassName} !text-[14px] !text-[var(--meta)]`}
|
||||
className={`${linkClassName} !text-[15px] md:!text-[14px] !text-[var(--meta)]`}
|
||||
>
|
||||
Currículo
|
||||
</Link>
|
||||
@@ -127,14 +142,16 @@ export default function Footer({
|
||||
</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">
|
||||
<a
|
||||
<Link
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="inline-flex min-h-11 items-center"
|
||||
>
|
||||
<Logo theme="light" className="w-40" />
|
||||
</a>
|
||||
<p className="font-display text-[14px] leading-6 text-[var(--meta)]">
|
||||
{/* o ".dev" do logo é limão (#ccff00), quase invisível sobre o papel: o filtro o escurece (de 1,04:1
|
||||
para 4:1) e o resto do logo, já quase preto, quase não muda */}
|
||||
<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
|
||||
reservados.
|
||||
</p>
|
||||
|
||||
@@ -234,7 +234,7 @@ function GoogleAnalyticsConsent() {
|
||||
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
|
||||
<p
|
||||
id="analytics-consent-description"
|
||||
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-sm"
|
||||
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.{" "}
|
||||
<a
|
||||
@@ -273,7 +273,7 @@ function GoogleAnalyticsConsent() {
|
||||
<p
|
||||
role="alert"
|
||||
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}
|
||||
</p>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 type { WhatsAppContexts } from "@/lib/whatsapp";
|
||||
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
|
||||
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() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
useEffect(() => {
|
||||
@@ -32,12 +64,16 @@ describe("Header", () => {
|
||||
within(navigation)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual(["/#projects", "/#process", "/#about", "/cases", "/servicos"]);
|
||||
).toEqual(["/cases", "/servicos", "/#process", "/#about", "/ia"]);
|
||||
expect(
|
||||
within(navigation)
|
||||
.getAllByRole("link")
|
||||
.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(
|
||||
within(navigation).queryByRole("link", { name: "Experiência" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -77,11 +113,11 @@ describe("Header", () => {
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/#projects",
|
||||
"/#process",
|
||||
"/#about",
|
||||
"/cases",
|
||||
"/servicos",
|
||||
"/#process",
|
||||
"/#about",
|
||||
"/ia",
|
||||
"/#contact",
|
||||
]);
|
||||
expect(
|
||||
@@ -93,7 +129,7 @@ describe("Header", () => {
|
||||
const mobileLink = within(mobileNavigation).getByRole("link", {
|
||||
name: "Sobre",
|
||||
});
|
||||
mobileLink.focus();
|
||||
act(() => mobileLink.focus());
|
||||
fireEvent.keyDown(mobileLink, { key: "Escape" });
|
||||
const openButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||
expect(openButton).toHaveAttribute("aria-expanded", "false");
|
||||
@@ -118,7 +154,7 @@ describe("Header", () => {
|
||||
.find((link) => link.getAttribute("href") === href);
|
||||
if (!mobileLink)
|
||||
throw new Error(`Missing navigation destination: ${href}`);
|
||||
mobileLink.focus();
|
||||
act(() => mobileLink.focus());
|
||||
fireEvent.click(mobileLink);
|
||||
|
||||
expect(
|
||||
@@ -130,4 +166,111 @@ describe("Header", () => {
|
||||
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
@@ -1,13 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
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 WhatsAppLink from "./WhatsAppLink";
|
||||
|
||||
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 [scrolled, setScrolled] = useState(false);
|
||||
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">
|
||||
<a
|
||||
<HoverPrefetchLink
|
||||
href="/#home"
|
||||
aria-label="Evandro.dev.br — início"
|
||||
className="flex min-h-11 shrink-0 items-center no-underline"
|
||||
>
|
||||
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
|
||||
</a>
|
||||
</HoverPrefetchLink>
|
||||
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
@@ -60,12 +113,12 @@ export function Header() {
|
||||
<ul className="flex list-none items-center gap-sp-4">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<a
|
||||
<HoverPrefetchLink
|
||||
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"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</HoverPrefetchLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -73,12 +126,12 @@ export function Header() {
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="hidden xl:block">
|
||||
<a
|
||||
href="/#contact"
|
||||
<ContactCta
|
||||
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)]"
|
||||
>
|
||||
Falar sobre meu projeto
|
||||
</a>
|
||||
placement="header"
|
||||
viaWhatsApp={viaWhatsApp}
|
||||
subject={subject}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
@@ -104,41 +157,37 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<div className="ds-section-shell flex flex-col py-sp-6">
|
||||
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
||||
<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
|
||||
id={MENU_ID}
|
||||
aria-hidden={!mobileMenuOpen}
|
||||
aria-label="Navegação mobile"
|
||||
className="flex flex-1 flex-col"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<ul className="flex list-none flex-col gap-sp-2">
|
||||
<ul className="flex list-none flex-col">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
strength={0}
|
||||
wrapperClassName="w-full"
|
||||
<li key={item.href} className="border-b border-white/10">
|
||||
<HoverPrefetchLink
|
||||
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}
|
||||
>
|
||||
{item.label}
|
||||
</MagneticButton>
|
||||
</HoverPrefetchLink>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import Hero from "./Hero";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): statItems() (sem stats, sem anos, zero anos, com anos),
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -23,27 +23,37 @@ afterEach(() => {
|
||||
delete window.gtag;
|
||||
});
|
||||
|
||||
describe("Hero: números de experiência (statItems)", () => {
|
||||
describe("Hero: anos de experiência (na linha de clientes)", () => {
|
||||
it.each([
|
||||
["no stats at all", undefined],
|
||||
["stats without years", {}],
|
||||
["zero years", { years: 0 }],
|
||||
])("path: %s -> no figures block", (_label, stats) => {
|
||||
])("path: %s -> no experience text", (_label, stats) => {
|
||||
const { container } = render(<Hero stats={stats} />);
|
||||
|
||||
expect(container.querySelector("dl")).toBeNull();
|
||||
expect(screen.queryByText("anos de experiência")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: years given -> one figure with the number and its label", () => {
|
||||
const { container } = render(<Hero stats={{ years: 7 }} />);
|
||||
|
||||
const items = container.querySelectorAll("dl > div");
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].querySelector("dd")).toHaveTextContent("7");
|
||||
expect(items[0].querySelector("dt")).toHaveTextContent(
|
||||
"anos de experiência",
|
||||
it("path: years given -> the number and its label sit in the same line as the clients", () => {
|
||||
const { container } = render(
|
||||
<Hero
|
||||
stats={{ years: 7 }}
|
||||
clients={[{ id: "lavacao-047", name: "Lavação 047" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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 { useReducedMotion } from "motion/react";
|
||||
import { hydrateRoot, type Root } from "react-dom/client";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import Hero from "./Hero";
|
||||
|
||||
jest.mock("motion/react", () => ({
|
||||
...jest.requireActual("motion/react"),
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -26,13 +20,10 @@ it.each([false, true])(
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
const preference = jest.mocked(useReducedMotion);
|
||||
preference.mockReturnValue(null);
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = renderToString(<Hero />);
|
||||
document.body.append(container);
|
||||
|
||||
preference.mockReturnValue(reducedMotion);
|
||||
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
|
||||
const recoverableErrors: unknown[] = [];
|
||||
let root: Root | undefined;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
@@ -10,30 +9,6 @@ const clients = [
|
||||
{ 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", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
@@ -63,10 +38,21 @@ describe("Hero", () => {
|
||||
render(<Hero />);
|
||||
|
||||
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", () => {
|
||||
render(<Hero />);
|
||||
|
||||
@@ -92,7 +78,7 @@ describe("Hero", () => {
|
||||
|
||||
expect(
|
||||
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();
|
||||
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("5")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("term")).toBeNull();
|
||||
expect(screen.queryByText(/projetos desenvolvidos/i)).toBeNull();
|
||||
expect(screen.queryByText(/públicos|privados|mais de/i)).toBeNull();
|
||||
});
|
||||
@@ -134,7 +121,10 @@ describe("Hero", () => {
|
||||
}
|
||||
expect(images[0]).toHaveAttribute(
|
||||
"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");
|
||||
});
|
||||
|
||||
+24
-43
@@ -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 = {
|
||||
years?: number;
|
||||
};
|
||||
|
||||
type StatItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function statItems(stats?: HeroStats): StatItem[] {
|
||||
if (!stats?.years) return [];
|
||||
return [{ value: String(stats.years), label: "anos de experiência" }];
|
||||
}
|
||||
|
||||
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
|
||||
const ACCENT = new Set([5, 6, 7]);
|
||||
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||
const TITLE =
|
||||
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
|
||||
" ",
|
||||
);
|
||||
const ACCENT = new Set([1, 2]);
|
||||
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha."
|
||||
.split(" ")
|
||||
.map((word, index) => ({
|
||||
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. */
|
||||
const CAPTIONS = [
|
||||
@@ -63,11 +60,9 @@ export default function Hero({
|
||||
}: {
|
||||
stats?: HeroStats;
|
||||
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;
|
||||
}) {
|
||||
const items = statItems(stats);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="home"
|
||||
@@ -94,12 +89,12 @@ export default function Hero({
|
||||
Evandro Fonseca Junior · Engenharia de software
|
||||
</p>
|
||||
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||
{/* No desktop o título para antes da coluna HeroWork (largura da coluna: clamp(300px,24vw,400px)). */}
|
||||
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw,calc(100%-clamp(300px,24vw,400px)-24px))] text-[clamp(38px,min(6vw,9svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
|
||||
{TITLE.map((word, index) => (
|
||||
<Fragment key={word}>
|
||||
{/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
|
||||
<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, key, accent }, index) => (
|
||||
<Fragment key={key}>
|
||||
<span
|
||||
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
|
||||
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
|
||||
style={{ "--i": index } as CSSProperties}
|
||||
>
|
||||
{word}
|
||||
@@ -107,10 +102,9 @@ export default function Hero({
|
||||
</Fragment>
|
||||
))}
|
||||
</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))]">
|
||||
Para empresas que cresceram além das planilhas e dos sistemas
|
||||
prontos: sites, sistemas web, aplicativos e integrações, com contato
|
||||
direto com quem desenvolve.
|
||||
<p 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%)]">
|
||||
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
|
||||
fala direto com quem desenvolve.
|
||||
</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">
|
||||
@@ -141,7 +135,8 @@ export default function Hero({
|
||||
|
||||
<ClientStrip
|
||||
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} />
|
||||
@@ -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="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 */}
|
||||
<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)]">
|
||||
{["Sistemas web", "Aplicativos", "Integrações"].map(
|
||||
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
|
||||
(service) => (
|
||||
<li key={service} className="flex items-center gap-sp-2">
|
||||
<span
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { BrowserFrame } from "./showcase/BrowserFrame";
|
||||
import { PhoneFrame } from "./showcase/PhoneFrame";
|
||||
import "./showcase/showcase.css";
|
||||
|
||||
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
|
||||
export type HeroWorkUrl = { text: string; secure: boolean } | null;
|
||||
|
||||
const DESKTOP_PROJECT = "lavacao-047";
|
||||
const DESKTOP_PROJECT = "sev-modaintima";
|
||||
const MOBILE_PROJECT = "as-servicos";
|
||||
|
||||
/**
|
||||
* Trabalho real ao lado do título, só em telas de 1024 px ou mais: o pôster do site da Lavação 047 no navegador
|
||||
* e o do aplicativo da AS Serviços no celular. A coluna inteira fica escondida (display: none) abaixo disso,
|
||||
* então as imagens (lazy) nem são baixadas no celular, e elas são menores que o título para o H1 seguir como maior elemento.
|
||||
* 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
|
||||
* (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo da AS Serviços no celular.
|
||||
* 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 }) {
|
||||
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
||||
@@ -21,13 +21,13 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="hero-work"
|
||||
className="hero-in pointer-events-none absolute right-sp-12 bottom-sp-8 hidden w-[clamp(300px,24vw,400px)] [--d:.8s] lg:block"
|
||||
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%]">
|
||||
{/* biome-ignore lint/performance/noImgElement: pôster de 1440 px já otimizado em /public/videos; next/image pediria o otimizador para um arquivo menor que o necessário */}
|
||||
<img
|
||||
src={desktop.poster}
|
||||
alt="Site da Lavação 047 aberto no computador"
|
||||
alt="Site da SEV Moda Íntima aberto no computador"
|
||||
width={desktop.width}
|
||||
height={desktop.height}
|
||||
loading="lazy"
|
||||
@@ -36,7 +36,11 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
</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 */}
|
||||
<img
|
||||
src={phone.poster}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,25 @@ it("talks about 'um projeto' when no service is given", () => {
|
||||
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"'])(
|
||||
"keeps a native destination without collecting or replaying when consent is %s",
|
||||
async (consent) => {
|
||||
|
||||
@@ -10,6 +10,8 @@ interface WhatsAppLinkProps {
|
||||
/** Assunto da conversa, na frase "Quero conversar sobre {service}". Sem ele, a mensagem fala de "um projeto". */
|
||||
service?: 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". */
|
||||
@@ -20,12 +22,15 @@ export default function WhatsAppLink({
|
||||
className = "",
|
||||
service,
|
||||
placement,
|
||||
onClick,
|
||||
}: 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 (
|
||||
<a
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
@@ -33,6 +38,7 @@ export default function WhatsAppLink({
|
||||
onClick={(event) => {
|
||||
if (event.button === 0)
|
||||
trackWhatsAppOpen({ service: analyticsService, placement });
|
||||
onClick?.();
|
||||
}}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button === 1)
|
||||
|
||||
@@ -1,54 +1,14 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { primaryButton, secondaryButtonLight } from "./button-styles";
|
||||
import { WorkModalities } from "./WorkModalities";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de WorkModalities e TierCard: movimento normal x reduzido, plano em destaque
|
||||
* (botão e borda) x demais planos, e serviços do hub ausentes x presentes.
|
||||
* O `motion/react` é trocado por elementos simples que registram as props de animação.
|
||||
* Testes de caminhos-base de WorkModalities e TierCard: a entrada dos planos (Reveal, CSS), o toque e o passe do
|
||||
* mouse (classes cv-press e cv-lift, sem foco nos cartões), plano em destaque (botão e borda) x demais planos,
|
||||
* 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", () => ({
|
||||
Section: ({ children, id }: { children?: React.ReactNode; id?: string }) => (
|
||||
<section id={id}>{children}</section>
|
||||
@@ -57,94 +17,91 @@ jest.mock("./Section", () => ({
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
|
||||
|
||||
type Seen = {
|
||||
variants?: {
|
||||
hidden: Record<string, unknown>;
|
||||
visible: { transition: Record<string, unknown> };
|
||||
};
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
};
|
||||
|
||||
/** O hub inteiro, fora da ordem da home: a home mostra só sites, sistemas web e integrações, nesta ordem. */
|
||||
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",
|
||||
label: "Sites profissionais",
|
||||
description: "Presença digital para a empresa.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/tauri-rust",
|
||||
label: "Aplicativos de desktop",
|
||||
description: "Programas rápidos para o computador.",
|
||||
href: "/servicos/engenharia-de-software",
|
||||
label: "Sistemas web e aplicativos mobile",
|
||||
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", () => {
|
||||
it("path: normal motion -> tiers rise in, with hover lift and press feedback", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(false);
|
||||
it("path: tiers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
render(<WorkModalities />);
|
||||
|
||||
const items = seen("div").filter(
|
||||
(props) => props.variants?.hidden?.opacity === 0,
|
||||
);
|
||||
expect(items).toHaveLength(3);
|
||||
for (const item of items) {
|
||||
expect(item.variants?.hidden).toEqual({
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
clipPath: "inset(100% 0 0 0)",
|
||||
});
|
||||
expect(item.variants?.visible.transition).toEqual({
|
||||
duration: 0.75,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
});
|
||||
}
|
||||
const cards = seen("article");
|
||||
expect(cards).toHaveLength(3);
|
||||
for (const card of cards) {
|
||||
expect(card.whileHover).toEqual({
|
||||
y: -4,
|
||||
boxShadow: "0 24px 50px rgba(0, 0, 0, 0.08)",
|
||||
});
|
||||
expect(card.whileTap).toEqual({ scale: 0.99 });
|
||||
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
||||
expect(reveals).toHaveLength(3);
|
||||
expect(
|
||||
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
||||
).toEqual(["20px", "20px", "20px"]);
|
||||
expect(
|
||||
reveals.map((element) =>
|
||||
element.style.getPropertyValue("--reveal-duration"),
|
||||
),
|
||||
).toEqual(["0.75s", "0.75s", "0.75s"]);
|
||||
expect(
|
||||
reveals.map((element) =>
|
||||
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
|
||||
),
|
||||
).toEqual([0.08, 0.16, 0.24]);
|
||||
// o estado escondido vive no CSS: sem opacidade nem transformação inline
|
||||
for (const element of reveals) {
|
||||
expect(element.style.opacity).toBe("");
|
||||
expect(element.style.transform).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("path: reduced motion -> tiers only fade, with no hover lift and no press feedback", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(true);
|
||||
it("path: the cards are not focus stops and keep no outline-none (touch and hover feedback come from CSS)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
render(<WorkModalities />);
|
||||
|
||||
const items = seen("div").filter(
|
||||
(props) => props.variants?.hidden?.opacity === 0,
|
||||
);
|
||||
expect(items).toHaveLength(3);
|
||||
for (const item of items) {
|
||||
expect(item.variants?.hidden).toEqual({ opacity: 0 });
|
||||
expect(item.variants?.visible.transition).toEqual({ duration: 0.25 });
|
||||
const cards = container.querySelectorAll("article");
|
||||
expect(cards).toHaveLength(3);
|
||||
for (const card of cards) {
|
||||
expect(card).not.toHaveAttribute("tabindex");
|
||||
expect(card.className).not.toContain("outline-none");
|
||||
expect(card).toHaveClass("cv-press", "cv-lift");
|
||||
}
|
||||
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", () => {
|
||||
it("path: the featured tier has the filled button and the thick border; the others have outline buttons", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(false);
|
||||
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const featured = screen.getByRole("link", {
|
||||
@@ -167,21 +124,17 @@ describe("WorkModalities: plano em destaque", () => {
|
||||
|
||||
describe("WorkModalities: serviços do hub", () => {
|
||||
it("path: no serviceLinks prop -> no service cards, the 'all services' link stays", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(false);
|
||||
|
||||
render(<WorkModalities />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Conhecer todos os serviços" }),
|
||||
screen.getByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toHaveAttribute("href", "/servicos");
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Sites profissionais/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: serviceLinks given -> one card per service, in order, linking to its page", () => {
|
||||
jest.mocked(useReducedMotion).mockReturnValue(false);
|
||||
|
||||
it("path: serviceLinks given -> only the three main services (sites, sistemas web, integrações), in that order", () => {
|
||||
const { container } = render(
|
||||
<WorkModalities serviceLinks={serviceLinks} />,
|
||||
);
|
||||
@@ -190,14 +143,69 @@ describe("WorkModalities: serviços do hub", () => {
|
||||
"[data-reel-stagger='left']",
|
||||
) as HTMLElement;
|
||||
const links = within(grid).getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
||||
serviceLinks.map(({ href }) => href),
|
||||
);
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/servicos/criacao-de-sites",
|
||||
"/servicos/engenharia-de-software",
|
||||
"/servicos/integracao-hardware-software-iot",
|
||||
]);
|
||||
expect(
|
||||
within(links[0]).getByText("Sites profissionais"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(links[1]).getByText("Programas rápidos para o computador."),
|
||||
within(links[1]).getByText("Sistemas sob medida."),
|
||||
).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/);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { ServiceLink } from "@/lib/site-navigation";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { primaryButton, secondaryButtonLight } from "./button-styles";
|
||||
import "./conversion.css";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { Section } from "./Section";
|
||||
|
||||
interface Tier {
|
||||
@@ -15,8 +13,8 @@ interface Tier {
|
||||
tagline: string;
|
||||
description: string;
|
||||
deliverables: string[];
|
||||
pricing: string;
|
||||
pricingNote: string;
|
||||
/** Primeiro passo concreto, sem prazo nem preço. */
|
||||
firstStep: string;
|
||||
featured?: boolean;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
@@ -36,8 +34,8 @@ const TIERS: Tier[] = [
|
||||
"Integrações e permissões de acesso",
|
||||
"Validação, publicação e documentação",
|
||||
],
|
||||
pricing: "Proposta por projeto",
|
||||
pricingNote: "Escopo, etapas e investimento definidos antes da execução.",
|
||||
firstStep:
|
||||
"Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
|
||||
featured: true,
|
||||
ctaLabel: "Conversar sobre meu sistema",
|
||||
ctaHref: "/#contact",
|
||||
@@ -55,8 +53,8 @@ const TIERS: Tier[] = [
|
||||
"Análise dos gargalos da aplicação",
|
||||
"Plano de correção por prioridade",
|
||||
],
|
||||
pricing: "Escopo de análise definido",
|
||||
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
|
||||
firstStep:
|
||||
"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",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
@@ -73,9 +71,8 @@ const TIERS: Tier[] = [
|
||||
"Testes das partes afetadas",
|
||||
"Planejamento de manutenção e operação",
|
||||
],
|
||||
pricing: "Etapas acordadas",
|
||||
pricingNote:
|
||||
"Prioridades, suporte e responsabilidades definidos em contrato.",
|
||||
firstStep:
|
||||
"Primeira conversa sobre o sistema e as ferramentas que você já usa. Depois, escopo e proposta por escrito.",
|
||||
ctaLabel: "Planejar a próxima etapa",
|
||||
ctaHref: "/#contact",
|
||||
},
|
||||
@@ -105,26 +102,14 @@ const PROCESS = [
|
||||
];
|
||||
|
||||
function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.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 ${
|
||||
<article
|
||||
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" : ""
|
||||
}`}
|
||||
style={{
|
||||
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">
|
||||
<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]">
|
||||
{tier.title}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
|
||||
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"
|
||||
>
|
||||
{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="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
|
||||
{tier.pricing}
|
||||
Primeiro passo
|
||||
</div>
|
||||
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]">
|
||||
{tier.pricingNote}
|
||||
<div className="font-display text-[15px] md:text-[13px] leading-[1.7] text-[var(--ink-80)]">
|
||||
{tier.firstStep}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,41 +161,27 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
>
|
||||
{tier.ctaLabel}
|
||||
</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({
|
||||
serviceLinks = [],
|
||||
}: {
|
||||
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
|
||||
serviceLinks?: ServiceLink[];
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
const mainServices = HOME_SERVICE_SLUGS.flatMap((slug) => {
|
||||
const service = serviceLinks.find(({ href }) => href.endsWith(`/${slug}`));
|
||||
return service ? [service] : [];
|
||||
});
|
||||
|
||||
return (
|
||||
<Section
|
||||
@@ -235,15 +206,15 @@ export function WorkModalities({
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/servicos" className={`shrink-0 ${secondaryButtonLight}`}>
|
||||
Conhecer todos os serviços
|
||||
Ver todos os serviços
|
||||
</Link>
|
||||
</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"
|
||||
>
|
||||
{serviceLinks.map((service) => (
|
||||
{mainServices.map((service) => (
|
||||
<Link
|
||||
key={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)]">
|
||||
{service.label}
|
||||
</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}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -260,19 +231,19 @@ export function WorkModalities({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.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"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{TIERS.map((tier) => (
|
||||
<motion.div key={tier.id} variants={itemVariants} className="min-w-0">
|
||||
<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">
|
||||
{TIERS.map((tier, index) => (
|
||||
<Reveal
|
||||
key={tier.id}
|
||||
y={20}
|
||||
duration={0.75}
|
||||
delay={0.08 * (index + 1)}
|
||||
className="min-w-0"
|
||||
>
|
||||
<TierCard tier={tier} />
|
||||
</motion.div>
|
||||
</Reveal>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div id="process" className="mt-sp-12 scroll-mt-24">
|
||||
<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,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,6 @@ const mockHeroProps = jest.fn();
|
||||
const mockProjectsProps = jest.fn();
|
||||
const mockModalitiesProps = jest.fn();
|
||||
const mockTestimonialsProps = jest.fn();
|
||||
const mockFooterProps = jest.fn();
|
||||
|
||||
jest.mock("../Hero", () => ({
|
||||
__esModule: true,
|
||||
@@ -44,6 +43,10 @@ jest.mock("../EngineeringWithAI", () => ({
|
||||
__esModule: true,
|
||||
default: () => <section data-testid="scene-ai" />,
|
||||
}));
|
||||
jest.mock("../HomeFaq", () => ({
|
||||
__esModule: true,
|
||||
default: () => <section data-testid="scene-faq" />,
|
||||
}));
|
||||
jest.mock("../Testimonials", () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown) => {
|
||||
@@ -55,17 +58,6 @@ jest.mock("../Contact", () => ({
|
||||
__esModule: true,
|
||||
default: () => <section data-testid="scene-contact" />,
|
||||
}));
|
||||
jest.mock("../Footer", () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown) => {
|
||||
mockFooterProps(props);
|
||||
return <footer data-testid="footer" />;
|
||||
},
|
||||
}));
|
||||
jest.mock("../FloatingWhatsApp", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="floating-whatsapp" />,
|
||||
}));
|
||||
jest.mock("./useCinematicScroll", () => ({ useCinematicScroll: jest.fn() }));
|
||||
jest.mock("./useHomeFragmentNavigation", () => ({
|
||||
useHomeFragmentNavigation: jest.fn(),
|
||||
@@ -86,27 +78,24 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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(
|
||||
<CinematicHome
|
||||
projects={[
|
||||
project("lavacao-047", "https://www.landing-lavacao.example.com/"),
|
||||
project("sev-modaintima", "https://sevmodaintima.com.br"),
|
||||
project(
|
||||
"lavacao-047",
|
||||
"https://www.landing-lavacao.vercel.app/pagina/",
|
||||
),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastHeroProps().workUrl).toEqual({
|
||||
text: "landing-lavacao.vercel.app/pagina",
|
||||
text: "sevmodaintima.com.br",
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -114,7 +103,19 @@ describe("CinematicHome: endereço do trabalho em destaque", () => {
|
||||
it("path: the featured project is not in the list -> no address", () => {
|
||||
render(
|
||||
<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(mockTestimonialsProps).toHaveBeenCalledWith({ items: testimonials });
|
||||
expect(mockModalitiesProps).toHaveBeenCalledWith({ serviceLinks });
|
||||
expect(mockFooterProps).toHaveBeenCalledWith({ serviceLinks });
|
||||
});
|
||||
|
||||
it("path: optional props omitted -> the scenes receive undefined and decide their own defaults", () => {
|
||||
@@ -168,10 +168,9 @@ describe("CinematicHome: props e estrutura", () => {
|
||||
expect(mockModalitiesProps).toHaveBeenCalledWith({
|
||||
serviceLinks: undefined,
|
||||
});
|
||||
expect(mockFooterProps).toHaveBeenCalledWith({ serviceLinks: undefined });
|
||||
});
|
||||
|
||||
it("path: scenes sit in order inside <main>; the footer and the floating button sit outside it", () => {
|
||||
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={[]} />);
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
@@ -186,10 +185,12 @@ describe("CinematicHome: props e estrutura", () => {
|
||||
"scene-modalities",
|
||||
"scene-ai",
|
||||
"scene-testimonials",
|
||||
"scene-faq",
|
||||
"scene-contact",
|
||||
]);
|
||||
expect(main).not.toContainElement(screen.getByTestId("footer"));
|
||||
expect(main).not.toContainElement(screen.getByTestId("floating-whatsapp"));
|
||||
// o rodapé é do layout: a home não desenha um (seria o segundo da página)
|
||||
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", () => {
|
||||
|
||||
@@ -8,9 +8,8 @@ import About from "../About";
|
||||
import type { ClientLink } from "../ClientStrip";
|
||||
import Contact from "../Contact";
|
||||
import EngineeringWithAI from "../EngineeringWithAI";
|
||||
import FloatingWhatsApp from "../FloatingWhatsApp";
|
||||
import Footer from "../Footer";
|
||||
import Hero, { type HeroStats } from "../Hero";
|
||||
import HomeFaq from "../HomeFaq";
|
||||
import Projects from "../Projects";
|
||||
import { displayUrl } from "../showcase/BrowserFrame";
|
||||
import Testimonials from "../Testimonials";
|
||||
@@ -18,8 +17,8 @@ import { WorkModalities } from "../WorkModalities";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
/** Projeto cujo endereço aparece na barra do navegador de HeroWork. */
|
||||
const HERO_WORK_PROJECT = "lavacao-047";
|
||||
/** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */
|
||||
const HERO_WORK_PROJECT = "sev-modaintima";
|
||||
|
||||
export default function CinematicHome({
|
||||
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. */
|
||||
serviceLinks?: ServiceLink[];
|
||||
}) {
|
||||
const heroWorkUrl = displayUrl({
|
||||
const shownUrl = displayUrl({
|
||||
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
|
||||
?.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 mainRef = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(mainRef);
|
||||
@@ -47,8 +49,8 @@ export default function CinematicHome({
|
||||
useEffect(() => onLayoutReady(), [onLayoutReady]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
|
||||
<div className="overflow-x-clip">
|
||||
{/* 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">
|
||||
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
|
||||
|
||||
@@ -62,12 +64,10 @@ export default function CinematicHome({
|
||||
|
||||
<Testimonials items={testimonials} />
|
||||
|
||||
<HomeFaq />
|
||||
|
||||
<Contact />
|
||||
</main>
|
||||
<div className="relative z-10">
|
||||
<Footer serviceLinks={serviceLinks} />
|
||||
</div>
|
||||
<FloatingWhatsApp />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,3 +27,45 @@
|
||||
letter-spacing: normal;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
|
||||
export const alt = "Começou um sistema com IA? Revisão e continuidade";
|
||||
export const alt = "Revisão de app feito com IA";
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = OG_CONTENT_TYPE;
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function Image() {
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow="IA no desenvolvimento"
|
||||
title="Começou um sistema com IA? Dá para partir daí."
|
||||
description="Revisão do que foi gerado, correção dos riscos e continuidade até a produção."
|
||||
title="Revisão de app feito com IA"
|
||||
description="Falhas de acesso a dados, o que aproveitar, o que refazer e um orçamento para pôr em produção."
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
|
||||
+61
-15
@@ -1,22 +1,26 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import AiPage, { metadata } from "./page";
|
||||
|
||||
it("uses its own title without the site suffix being added twice", () => {
|
||||
expect(metadata.title).toEqual({
|
||||
absolute:
|
||||
"Começou um sistema com IA? Revisão e continuidade | Evandro Fonseca Junior",
|
||||
});
|
||||
it("uses a short search title: with the layout suffix it fits in 44 characters", () => {
|
||||
// o template de app/layout.tsx acrescenta " | Evandro.dev.br"
|
||||
const suffix = " | Evandro.dev.br";
|
||||
expect(metadata.title).toBe("Revisão de app feito com IA");
|
||||
expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(44);
|
||||
expect(metadata).toMatchObject({
|
||||
description: expect.any(String),
|
||||
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", () => {
|
||||
render(<AiPage />);
|
||||
|
||||
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 [
|
||||
"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();
|
||||
}
|
||||
for (const incident of [/Lovable/, /Replit/, /Base44/]) {
|
||||
for (const incident of [/Lovable:/, /Replit:/, /Base44:/]) {
|
||||
expect(screen.getByRole("heading", { name: incident })).toBeInTheDocument();
|
||||
}
|
||||
expect(
|
||||
@@ -40,16 +44,16 @@ it("offers WhatsApp conversations about reviewing an AI-built system", () => {
|
||||
.getAllByRole("link")
|
||||
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
|
||||
expect(links.map((link) => link.textContent)).toEqual([
|
||||
"Falar sobre meu sistema",
|
||||
"Pedir a revisão do meu app",
|
||||
"Avaliar meu projeto",
|
||||
]);
|
||||
for (const link of links) {
|
||||
const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
|
||||
"text",
|
||||
);
|
||||
// a mensagem tem de formar uma frase: "Quero conversar sobre a revisão de um sistema feito com IA."
|
||||
// a mensagem tem de formar uma frase: "Quero conversar sobre a revisão de um aplicativo feito com IA."
|
||||
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", () => {
|
||||
render(<AiPage />);
|
||||
|
||||
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||
const breadcrumb = screen.getByRole("navigation", {
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
expect(
|
||||
within(breadcrumb).getByRole("link", { name: "Início" }),
|
||||
).toHaveAttribute("href", "/");
|
||||
expect(within(breadcrumb).getByText("IA no desenvolvimento")).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(
|
||||
within(breadcrumb).getByText("Revisão de app feito com IA"),
|
||||
).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
@@ -10,26 +10,51 @@ import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
import { siteUrl } from "@/lib/site-content";
|
||||
import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp";
|
||||
|
||||
const pageUrl = `${siteUrl}/ia`;
|
||||
const pageTitle =
|
||||
"Começou um sistema com IA? Revisão e continuidade | Evandro Fonseca Junior";
|
||||
/** Curto de propósito: o template de app/layout.tsx acrescenta " | Evandro.dev.br" e o conjunto cabe em 44 caracteres. */
|
||||
const pageTitle = "Revisão de app feito com IA";
|
||||
const pageDescription =
|
||||
"Protótipo ou sistema criado com IA? Avalio o que pode ser aproveitado, corrijo permissões, dados e publicação e levo o projeto até a produção.";
|
||||
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp. */
|
||||
const WHATSAPP_SERVICE = "a revisão de um sistema feito com IA";
|
||||
"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 (a mesma do botão flutuante em /ia). */
|
||||
const WHATSAPP_SERVICE = AI_REVIEW_SUBJECT;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: pageTitle },
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url: pageUrl,
|
||||
title: pageTitle,
|
||||
// as prévias de compartilhamento não passam pelo template do título
|
||||
title: `${pageTitle} | Evandro.dev.br`,
|
||||
description: pageDescription,
|
||||
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 = [
|
||||
{
|
||||
title: "Quem pode acessar cada dado?",
|
||||
@@ -98,7 +123,7 @@ export default function AiPage() {
|
||||
"@graph": [
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["IA no desenvolvimento", pageUrl],
|
||||
["Revisão de app feito com IA", pageUrl],
|
||||
] as const),
|
||||
],
|
||||
};
|
||||
@@ -112,12 +137,12 @@ export default function AiPage() {
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ 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>
|
||||
<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>
|
||||
<p className="ds-copy max-w-[62ch]">
|
||||
A IA permite testar ideias e construir protótipos mesmo sem
|
||||
@@ -131,7 +156,7 @@ export default function AiPage() {
|
||||
service={WHATSAPP_SERVICE}
|
||||
placement="ia_hero"
|
||||
>
|
||||
Falar sobre meu sistema
|
||||
Pedir a revisão do meu app
|
||||
</WhatsAppLink>
|
||||
<Link href="/cases" className={textLinkDark}>
|
||||
Ver projetos entregues
|
||||
@@ -140,6 +165,34 @@ export default function AiPage() {
|
||||
</div>
|
||||
</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">
|
||||
<div className="ds-section-shell">
|
||||
<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">
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="ia-casos-title" className="ds-section-title max-w-[26ch]">
|
||||
O que casos documentados ensinam
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[75ch]">
|
||||
São episódios de 2025, não avaliações atuais das plataformas. Eles
|
||||
mostram riscos de configuração, permissões e operação; não provam
|
||||
que todo sistema feito com IA seja inseguro ou que um profissional
|
||||
elimine qualquer risco.
|
||||
</p>
|
||||
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
|
||||
{INCIDENTS.map((incident) => (
|
||||
<article key={incident.title} className="ds-card">
|
||||
<h3 className="ds-card-title !mt-0">{incident.title}</h3>
|
||||
<p className="ds-copy">{incident.summary}</p>
|
||||
<p className="ds-copy">
|
||||
<strong>O cuidado técnico:</strong> {incident.care}
|
||||
</p>
|
||||
<ul className="mt-sp-4 flex flex-col gap-sp-1">
|
||||
{incident.links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4"
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{/* Recolhido: as notícias sustentam a oferta, mas não são o que a pessoa veio ver. */}
|
||||
<details className="group border-y border-[var(--border)]">
|
||||
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
|
||||
<h2
|
||||
id="ia-casos-title"
|
||||
className="font-display text-[clamp(20px,3vw,28px)] font-semibold leading-tight tracking-[-0.02em]"
|
||||
>
|
||||
O que casos documentados ensinam
|
||||
</h2>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shrink-0 font-mono text-2xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<div className="pb-sp-8">
|
||||
<p className="ds-copy max-w-[75ch]">
|
||||
São episódios de 2025, não avaliações atuais das plataformas.
|
||||
Eles mostram riscos de configuração, permissões e operação; não
|
||||
provam que todo sistema feito com IA seja inseguro ou que um
|
||||
profissional elimine qualquer risco.
|
||||
</p>
|
||||
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
|
||||
{INCIDENTS.map((incident) => (
|
||||
<article key={incident.title} className="ds-card">
|
||||
<h3 className="ds-card-title !mt-0">{incident.title}</h3>
|
||||
<p className="ds-copy">{incident.summary}</p>
|
||||
<p className="ds-copy">
|
||||
<strong>O cuidado técnico:</strong> {incident.care}
|
||||
</p>
|
||||
<ul className="mt-sp-4 flex flex-col gap-sp-1">
|
||||
{incident.links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
className="inline-flex min-h-11 items-center text-[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>
|
||||
</section>
|
||||
|
||||
@@ -229,7 +298,7 @@ export default function AiPage() {
|
||||
adequação.
|
||||
</p>
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -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
@@ -1,18 +1,41 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist_Mono, Space_Grotesk } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import type { ReactNode } from "react";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { 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 FloatingWhatsApp from "./components/FloatingWhatsApp";
|
||||
import Footer from "./components/Footer";
|
||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
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 =
|
||||
"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({
|
||||
subsets: ["latin"],
|
||||
@@ -28,27 +51,10 @@ const geistMono = 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 = {
|
||||
title: {
|
||||
default: defaultTitle,
|
||||
template: "%s | Evandro Fonseca Junior",
|
||||
template: `%s${titleSuffix}`,
|
||||
},
|
||||
description: seoDescription,
|
||||
authors: [{ name: "Evandro Fonseca Junior" }],
|
||||
@@ -57,9 +63,6 @@ export const metadata: Metadata = {
|
||||
applicationName: "Evandro.dev.br",
|
||||
category: "Technology",
|
||||
metadataBase: new URL(siteUrl),
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
@@ -77,7 +80,7 @@ export const metadata: Metadata = {
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
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: [
|
||||
{
|
||||
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 (
|
||||
<html
|
||||
lang="pt-BR"
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
{/* Set arrival policy before native restoration and client-side layout changes. */}
|
||||
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style>
|
||||
{/* Sem JavaScript as animações de entrada (opacity:0 inline vindo do servidor e palavras de título em CSS) não rodam: tudo precisa aparecer. */}
|
||||
<noscript>
|
||||
<style>{`[style*="opacity:0"],[style*="clip-path"]{opacity:1!important;transform:none!important;clip-path:none!important}.text-reveal-word{opacity:1!important;transform:none!important}`}</style>
|
||||
<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>
|
||||
<script>{`
|
||||
addEventListener("load", () => {
|
||||
@@ -161,11 +164,16 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
</a>
|
||||
{/* Aviso de cookies logo depois do "pular": fica cedo na ordem do Tab, ainda que fixo no rodapé da tela. */}
|
||||
<GoogleAnalytics />
|
||||
<Header />
|
||||
<Header whatsAppContexts={whatsAppContexts} />
|
||||
<CinematicOverlays />
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</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>
|
||||
</html>
|
||||
);
|
||||
|
||||
+2
-2
@@ -2,10 +2,10 @@ import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Evandro.dev.br — Sistemas sob medida para empresas",
|
||||
name: "Evandro.dev.br — Sites e sistemas para empresas",
|
||||
short_name: "Evandro.dev",
|
||||
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: "/",
|
||||
display: "standalone",
|
||||
background_color: "#000000",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
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 contentType = OG_CONTENT_TYPE;
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow="Engenharia de software para empresas"
|
||||
title="Sistemas sob medida."
|
||||
description="Da ideia à produção. Sistemas web, aplicativos e integrações com Evandro Fonseca Junior."
|
||||
eyebrow="Sites e sistemas para empresas"
|
||||
title="Sites e sistemas que trazem pedidos."
|
||||
description="Pedido pelo WhatsApp e operação fora da planilha. Com Evandro Fonseca Junior, de Garuva, SC."
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: "/" });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
|
||||
import { clientNames, isClientProject, testimonials } from "@/lib/proof";
|
||||
import { serviceHubLinks } from "@/lib/site-content";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
function yearsOfExperience() {
|
||||
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
|
||||
|
||||
Reference in new issue
Block a user