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