feat(home): seção Sobre com prova por ponto, contato por oferta e hero com a Lavação 047 na tela larga

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:26 -03:00
1 parent d356c364e0
commit 9fd1570426
33 files changed
+1128 -369

No files matched your search

+80 -17
View File
@@ -1,6 +1,23 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import About from "./About"; import About from "./About";
// o Link do Next, trocado por um <a> que mostra o prefetch recebido: HoverPrefetchLink começa em false
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>
),
}));
/** /**
* Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o * Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o
* feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de * feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de
@@ -89,24 +106,69 @@ describe("About", () => {
expect(cards[3]).toHaveClass("cv-press"); expect(cards[3]).toHaveClass("cv-press");
}); });
it("path: the story, the profile points and the résumé link are all there", () => { it("path: the section has three points, each with the proof that backs it, and the résumé link", () => {
render(<About />); const { container } = render(<About />);
const titles = [
"Quem conversa com você escreve o código",
"Aproveito o que você já tem",
"Do código ao sistema no ar",
];
for (const name of titles) {
expect(
screen.getByRole("heading", { level: 3, name }),
).toBeInTheDocument();
}
// no máximo três pontos: os três cartões da história; o cartão do perfil não é um quarto ponto, só diz do que cuido
const story = [...container.querySelectorAll("article")].slice(0, 3);
expect(story).toHaveLength(3);
for (const card of story) {
expect(within(card).getAllByText("Na prática:")).toHaveLength(1);
}
// a prova de cada ponto é concreta: a Apolo desde 2022, a revisão e os cases entregues
expect(story[0]).toHaveTextContent(
/Na prática: Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT/,
);
expect(story[1]).toHaveTextContent(
/Na prática: Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer/,
);
expect(story[2]).toHaveTextContent(
/Na prática: Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços\./,
);
expect( expect(
screen.getByRole("heading", { within(story[1]).getByRole("link", {
level: 3, name: "Ver como funciona a revisão",
name: "Sem intermediários entre o pedido e o sistema",
}), }),
).toBeInTheDocument(); ).toHaveAttribute("href", "/ia");
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");
}); });
it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work", () => { it("path: the idea of talking to whoever develops shows up once, not in five texts", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(
/Sem intermediários|Uma pessoa cuida|Quem responde pelo projeto|acompanha as decisões|fala com quem desenvolve|Solução do tamanho|Tudo funcionando junto/,
);
expect(container.textContent?.match(/mesma pessoa/g)).toHaveLength(1);
});
it("path: the résumé and the review links pre-fetch only when the visitor reaches them (HoverPrefetchLink), as in the rest of the home", () => {
render(<About />);
for (const name of [
"Ver trajetória completa",
"Ver como funciona a revisão",
]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
}
});
it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work once, as the proof of the first point", () => {
const { container } = render(<About />); const { container } = render(<About />);
expect(container.textContent).not.toMatch( expect(container.textContent).not.toMatch(
@@ -116,14 +178,14 @@ describe("About", () => {
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
"Evandro Fonseca Junior", "Evandro Fonseca Junior",
); );
expect(screen.getByTestId("about-description").textContent).toMatch( expect(screen.getByTestId("about-description").textContent).toBe(
/^Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia\./, "Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas.",
); );
// só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira // só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira
expect(container.textContent).not.toMatch( expect(container.textContent).not.toMatch(
/os sistemas internos e a IoT da Apolo/, /os sistemas internos e a IoT da Apolo/,
); );
// a frase da Apolo aparece uma só vez // a frase da Apolo aparece uma só vez (na prova do primeiro ponto)
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1); expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
}); });
@@ -131,7 +193,7 @@ describe("About", () => {
const { container } = render(<About />); const { container } = render(<About />);
expect(container.textContent).not.toMatch(/Como eu trabalho/); expect(container.textContent).not.toMatch(/Como eu trabalho/);
expect(screen.getByText("Acompanhamento")).toBeInTheDocument(); expect(screen.getByText("Quem desenvolve")).toBeInTheDocument();
}); });
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => { it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
@@ -142,13 +204,14 @@ describe("About", () => {
); );
}); });
it("path: the location label stays on one line and the columns stretch to the same height", () => { it("path: the location label stays on one line and the portrait card keeps its own height next to the text cards", () => {
const { container } = render(<About />); const { container } = render(<About />);
expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass( expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass(
"whitespace-nowrap", "whitespace-nowrap",
); );
expect(container.querySelector(".ds-grid-2")).toHaveClass("items-stretch"); // items-start: o retrato tem a altura do próprio conteúdo e acompanha a rolagem; só os cartões de texto se esticam entre si
expect(container.querySelector(".ds-grid-2")).toHaveClass("items-start");
const story = container.querySelectorAll("article"); const story = container.querySelectorAll("article");
for (const card of Array.from(story).slice(0, 3)) { for (const card of Array.from(story).slice(0, 3)) {
expect(card).toHaveClass("flex-1"); expect(card).toHaveClass("flex-1");
+48 -53
View File
@@ -1,48 +1,42 @@
import Link from "next/link";
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 "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal"; import { Reveal } from "./Reveal";
import { Section } from "./Section"; import { Section } from "./Section";
/**
* Os três pontos da seção, cada um com a prova que o sustenta. A ideia de que quem conversa com você é quem desenvolve
* aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida",
* "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero).
*/
const STORY_DATA = [ const STORY_DATA = [
{ {
label: "Acompanhamento", label: "Quem desenvolve",
title: "Sem intermediários entre o pedido e o sistema", title: "Quem conversa com você escreve o código",
content: content:
"Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.", "A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.",
proof:
"Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas.",
}, },
{ {
label: "Sem começar do zero", label: "Sem começar do zero",
title: "Aproveito o que você já tem", title: "Aproveito o que você já tem",
content: content:
"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.", "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.",
proof:
"Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer, com o motivo de cada parte.",
link: { href: "/ia", label: "Ver como funciona a revisão" },
}, },
{ {
label: "Depois da entrega", label: "Depois da entrega",
title: "Do código ao sistema no ar", title: "Do código ao sistema no ar",
content: content:
"Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, 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.",
}, proof:
]; "Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços.",
const PROFILE_POINTS = [
{
title: "Solução do tamanho do seu problema",
description:
"Escolho o que resolve o seu caso, sem complicar além do necessário.",
},
{
title: "Tudo funcionando junto",
description:
"A tela, os dados e os equipamentos são ligados para trabalhar como uma coisa só.",
},
{
title: "Quem responde pelo projeto",
description:
"Uma só pessoa decide a estrutura e desenvolve o sistema, do começo ao fim.",
}, },
]; ];
@@ -55,7 +49,7 @@ export default function About() {
id="about" id="about"
eyebrow="Quem conduz o projeto" eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior" title="Evandro Fonseca Junior"
description="Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia. Você acompanha as decisões técnicas ao longo da entrega." description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas."
disableChildrenAnimation disableChildrenAnimation
aside={ aside={
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left"> <div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
@@ -64,7 +58,8 @@ export default function About() {
</div> </div>
} }
> >
<div className="ds-grid-2 items-stretch"> {/* items-start: o cartão do retrato tem a altura do próprio conteúdo, e não a das três colunas de texto; no desktop ele acompanha a rolagem */}
<div className="ds-grid-2 items-start">
<div className="flex flex-col gap-sp-6"> <div className="flex flex-col gap-sp-6">
{STORY_DATA.map((step, index) => ( {STORY_DATA.map((step, index) => (
<Reveal <Reveal
@@ -81,6 +76,18 @@ export default function About() {
<p className="ds-copy" data-reel="right"> <p className="ds-copy" data-reel="right">
{step.content} {step.content}
</p> </p>
<p className="mt-sp-4 border-t border-[var(--border)] pt-sp-3 font-display text-[15px] leading-6 text-[var(--ink)]">
<span className="font-semibold">Na prática: </span>
{step.proof}
</p>
{step.link && (
<HoverPrefetchLink
href={step.link.href}
className={`${textLink} self-start text-[var(--ink-80)]`}
>
{step.link.label}
</HoverPrefetchLink>
)}
</article> </article>
</Reveal> </Reveal>
))} ))}
@@ -90,24 +97,25 @@ export default function About() {
y={20} y={20}
duration={0.8} duration={0.8}
delay={cardDelay(STORY_DATA.length)} delay={cardDelay(STORY_DATA.length)}
className="h-full" className="lg:sticky lg:top-28"
> >
<article className="ds-card cv-press cv-lift h-full"> <article className="ds-card cv-press cv-lift">
<div className="flex items-center gap-sp-5"> <figure>
<LazyRevealImage <LazyRevealImage
src={profilePhoto} src={profilePhoto}
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC" alt="Retrato de Evandro Fonseca Junior"
className="aspect-square w-28 shrink-0 overflow-hidden rounded-[6px] border border-[var(--border)] sm:w-32" className="aspect-[4/3] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-cover" imageClassName="object-cover object-[50%_25%]"
sizes="128px" sizes="(min-width: 1024px) 50vw, 100vw"
/> />
<div className="min-w-0"> <figcaption className="mt-sp-3 font-display text-[15px] leading-6 text-[var(--ink-80)]">
<div className="ds-kicker">Da ideia ao sistema no ar</div> Evandro Fonseca Junior · Engenheiro da computação · Garuva, SC
<h3 className="ds-card-title !mt-sp-2"> </figcaption>
Uma pessoa cuida do sistema inteiro </figure>
</h3> <div className="ds-kicker mt-sp-6">Do que eu cuido</div>
</div> <h3 className="ds-card-title !mt-sp-2">
</div> Tela, dados e equipamentos
</h3>
<DrawLine delay={0.05} /> <DrawLine delay={0.05} />
<p className="ds-copy" data-reel="top"> <p className="ds-copy" data-reel="top">
Cuido da tela que sua equipe usa, dos dados por trás dela e, Cuido da tela que sua equipe usa, dos dados por trás dela e,
@@ -115,25 +123,12 @@ export default function About() {
sistema. Ver tudo junto ajuda a decidir o que construir, o que sistema. Ver tudo junto ajuda a decidir o que construir, o que
conectar e o que dá para aproveitar. conectar e o que dá para aproveitar.
</p> </p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom"> <HoverPrefetchLink
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<div className="text-[15px] font-medium tracking-[0.02em]">
{point.title}
</div>
<p className="ds-copy !mt-sp-2">{point.description}</p>
</div>
))}
</div>
<Link
href="/curriculo" href="/curriculo"
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`} className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
> >
Ver trajetória completa Ver trajetória completa
</Link> </HoverPrefetchLink>
</article> </article>
</Reveal> </Reveal>
</div> </div>
+18 -8
View File
@@ -3,7 +3,7 @@ import Contact from "./Contact";
import { CONTACT_KIND_EVENT, DEFAULT_CONTACT_SUBJECT } from "./contact-subject"; import { CONTACT_KIND_EVENT, DEFAULT_CONTACT_SUBJECT } from "./contact-subject";
/** /**
* Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact`), complemento de * Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact-form`), complemento de
* Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento. * Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento.
*/ */
@@ -38,14 +38,22 @@ describe("Contact: tipo no endereço", () => {
}); });
it.each([ it.each([
["/?tipo=site#contact", "Site para empresa"], ["/?tipo=site#contact-form", "Site que traz pedidos pelo WhatsApp"],
["/?tipo=sistema#contact", "Sistemas web sob medida"],
[ [
"/?utm_source=x&tipo=revisao#contact", "/?tipo=sistema#contact-form",
"Revisão ou diagnóstico de sistema", "Sistema para tirar a operação da planilha",
], ],
[
"/?utm_source=x&tipo=revisao#contact-form",
"Revisão de sistema que já existe",
],
// o endereço antigo, com #contact, continua valendo: o tipo vem da busca, não do fragmento
["/?tipo=site#contact", "Site que traz pedidos pelo WhatsApp"],
// com o parâmetro repetido vale o primeiro // com o parâmetro repetido vale o primeiro
["/?tipo=site&tipo=revisao#contact", "Site para empresa"], [
"/?tipo=site&tipo=revisao#contact-form",
"Site que traz pedidos pelo WhatsApp",
],
])("path: %s (valid type) -> selects %s", (address, option) => { ])("path: %s (valid type) -> selects %s", (address, option) => {
window.history.replaceState(null, "", address); window.history.replaceState(null, "", address);
@@ -60,7 +68,9 @@ describe("Contact: tipo no endereço", () => {
choose("desconhecido"); choose("desconhecido");
expect(subjectSelect()).toHaveValue("Sistemas web sob medida"); expect(subjectSelect()).toHaveValue(
"Sistema para tirar a operação da planilha",
);
}); });
it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => { it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => {
@@ -69,6 +79,6 @@ describe("Contact: tipo no endereço", () => {
choose("revisao"); choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
}); });
}); });
+100 -16
View File
@@ -5,7 +5,8 @@ import { CONTACT_KIND_EVENT } from "./contact-subject";
/** /**
* O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara * O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara
* (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página), mais a opção de revisão de sistema. * (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página). As três ofertas entram no formulário
* com o nome que têm na home, o formulário avisa "Você escolheu: <oferta>" e o link direto do WhatsApp leva o tipo.
*/ */
const subjectSelect = () => const subjectSelect = () =>
@@ -23,18 +24,18 @@ afterEach(() => {
}); });
describe("Contact: opções do tipo de projeto", () => { describe("Contact: opções do tipo de projeto", () => {
it("lists 'Revisão ou diagnóstico de sistema' and keeps 'Ainda preciso definir' first and selected", () => { it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
render(<Contact />); render(<Contact />);
const options = within(subjectSelect()).getAllByRole("option"); const options = within(subjectSelect()).getAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([ expect(options.map((option) => option.textContent)).toEqual([
"Ainda preciso definir", "Ainda preciso definir",
"Site para empresa", "Site que traz pedidos pelo WhatsApp",
"Sistemas web sob medida", "Sistema para tirar a operação da planilha",
"Aplicativos", "Aplicativos",
"Integrações e automações", "Integrações e automações",
"Evolução de um sistema existente", "Evolução de um sistema existente",
"Revisão ou diagnóstico de sistema", "Revisão de sistema que já existe",
]); ]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir"); expect(subjectSelect()).toHaveValue("Ainda preciso definir");
}); });
@@ -45,24 +46,22 @@ describe("Contact: opções do tipo de projeto", () => {
await user.selectOptions( await user.selectOptions(
subjectSelect(), subjectSelect(),
"Revisão ou diagnóstico de sistema", "Revisão de sistema que já existe",
); );
const mail = screen.getByRole("link", { name: /e-mail/i }); const mail = screen.getByRole("link", { name: /e-mail/i });
const body = new URL(mail.getAttribute("href") ?? "").searchParams.get( const body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
"body", "body",
); );
expect(body).toContain( expect(body).toContain("Tipo de projeto: Revisão de sistema que já existe");
"Tipo de projeto: Revisão ou diagnóstico de sistema",
);
}); });
}); });
describe("Contact: tipo escolhido pela oferta", () => { describe("Contact: tipo escolhido pela oferta", () => {
it.each([ it.each([
["site", "Site para empresa"], ["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistemas web sob medida"], ["sistema", "Sistema para tirar a operação da planilha"],
["revisao", "Revisão ou diagnóstico de sistema"], ["revisao", "Revisão de sistema que já existe"],
])("the offer event with %s selects %s", (kind, option) => { ])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />); render(<Contact />);
@@ -80,7 +79,7 @@ describe("Contact: tipo escolhido pela oferta", () => {
expect(subjectSelect()).toHaveValue("Aplicativos"); expect(subjectSelect()).toHaveValue("Aplicativos");
choose("revisao"); choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
}); });
it.each([ it.each([
@@ -97,15 +96,20 @@ describe("Contact: tipo escolhido pela oferta", () => {
choose(kind); choose(kind);
expect(subjectSelect()).toHaveValue("Sistemas web sob medida"); expect(subjectSelect()).toHaveValue(
"Sistema para tirar a operação da planilha",
);
}); });
it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => { it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => {
window.history.replaceState(null, "", "/?tipo=revisao#contact"); window.history.replaceState(null, "", "/?tipo=revisao#contact-form");
render(<Contact />); render(<Contact />);
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
expect(screen.getByRole("status")).toHaveTextContent(
"Você escolheu: Revisão de sistema que já existe",
);
}); });
it("ignores an unknown ?tipo= in the address and keeps the default", () => { it("ignores an unknown ?tipo= in the address and keeps the default", () => {
@@ -133,6 +137,86 @@ describe("Contact: tipo escolhido pela oferta", () => {
}); });
}); });
describe("Contact: você escolheu e o WhatsApp direto", () => {
const directLink = () =>
screen.getByRole("link", { name: /Prefere conversar direto/ });
const directMessage = () =>
new URL(directLink().getAttribute("href") ?? "").searchParams.get("text");
it("renders the form card with the id the offers scroll to, and an empty status until an offer is chosen", () => {
const { container } = render(<Contact />);
expect(container.querySelector("form")).toHaveAttribute(
"id",
"contact-form",
);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["revisao", "Revisão de sistema que já existe"],
])(
"after the offer %s the form says 'Você escolheu: <name>'",
(kind, name) => {
render(<Contact />);
choose(kind);
expect(screen.getByRole("status")).toHaveTextContent(
`Você escolheu: ${name}`,
);
expect(within(screen.getByRole("status")).getByText(name).tagName).toBe(
"STRONG",
);
},
);
it("drops the message when the person picks another type in the form, and keeps it for an unknown offer", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
choose("desconhecido");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
choose("sistema");
expect(screen.getByRole("status")).not.toBeEmptyDOMElement();
await user.selectOptions(subjectSelect(), "Aplicativos");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("takes the chosen type to the direct WhatsApp link of the contact column, as a phrase, and back to 'um projeto'", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
expect(directMessage()).toBe(
"Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.",
);
choose("sistema");
expect(directMessage()).toBe(
"Olá, Evandro! Quero conversar sobre um sistema para tirar a operação da planilha. Contato iniciado pelo site evandro.dev.br.",
);
choose("revisao");
expect(directMessage()).toContain(
"Quero conversar sobre a revisão de um sistema que já existe.",
);
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
await user.selectOptions(
subjectSelect(),
"Site que traz pedidos pelo WhatsApp",
);
expect(directMessage()).toContain(
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
);
// um tipo sem oferta volta para a frase de "um projeto"
await user.selectOptions(subjectSelect(), "Aplicativos");
expect(directMessage()).toContain("Quero conversar sobre um projeto.");
// nada de nome de oferta com maiúscula na frase
expect(directMessage()).not.toMatch(/sobre [A-Z]/);
});
});
describe("Contact: texto da seção", () => { describe("Contact: texto da seção", () => {
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => { it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
const { container } = render(<Contact />); const { container } = render(<Contact />);
+1 -1
View File
@@ -147,7 +147,7 @@ it("offers 'Ainda preciso definir' as the first and default project type", () =>
(option) => option.value, (option) => option.value,
); );
expect(options[0]).toBe("Ainda preciso definir"); expect(options[0]).toBe("Ainda preciso definir");
expect(options).toContain("Site para empresa"); expect(options).toContain("Site que traz pedidos pelo WhatsApp");
expect(subject).toHaveValue("Ainda preciso definir"); expect(subject).toHaveValue("Ainda preciso definir");
}); });
+34 -2
View File
@@ -5,11 +5,19 @@ 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";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import { import {
buildWhatsAppUrl,
getWhatsAppDisplayNumber,
OFFER_SUBJECTS,
} from "@/lib/whatsapp";
import {
CONTACT_FORM_ID,
CONTACT_KIND_EVENT, CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM, CONTACT_KIND_PARAM,
CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS, CONTACT_SUBJECT_OPTIONS,
type ContactKind,
contactKindForSubject,
contactSubjectForKind, contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT, DEFAULT_CONTACT_SUBJECT,
} from "./contact-subject"; } from "./contact-subject";
@@ -162,13 +170,17 @@ export default function Contact() {
message: "", message: "",
}); });
const [errors, setErrors] = useState<FieldErrors>({}); const [errors, setErrors] = useState<FieldErrors>({});
// A oferta que a pessoa escolheu na home (botão ou `?tipo=`); some se ela trocar o tipo no próprio formulário.
const [chosenOffer, setChosenOffer] = useState<ContactKind | null>(null);
// O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra // O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada. // página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
useEffect(() => { useEffect(() => {
const choose = (kind: string | null | undefined) => { const choose = (kind: string | null | undefined) => {
const subject = contactSubjectForKind(kind); const subject = contactSubjectForKind(kind);
if (subject) setFormData((current) => ({ ...current, subject })); if (!subject) return;
setFormData((current) => ({ ...current, subject }));
setChosenOffer(contactKindForSubject(subject) ?? null);
}; };
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM)); choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
const onChoose = (event: Event) => const onChoose = (event: Event) =>
@@ -179,6 +191,7 @@ export default function Contact() {
const updateField = (field: ContactField | "subject", value: string) => { const updateField = (field: ContactField | "subject", value: string) => {
setFormData((current) => ({ ...current, [field]: value })); setFormData((current) => ({ ...current, [field]: value }));
if (field === "subject") setChosenOffer(null);
if (field !== "subject" && errors[field]) { if (field !== "subject" && errors[field]) {
setErrors((current) => ({ ...current, [field]: undefined })); setErrors((current) => ({ ...current, [field]: undefined }));
} }
@@ -195,6 +208,12 @@ export default function Contact() {
return firstInvalid !== undefined; return firstInvalid !== undefined;
}; };
// O link direto de WhatsApp leva o tipo escolhido (o nome da oferta, em frase) na mensagem; sem oferta, fala de "um projeto".
const selectedOffer = contactKindForSubject(formData.subject);
const directSubject = selectedOffer
? OFFER_SUBJECTS[selectedOffer]
: undefined;
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (hasInvalidField(e.currentTarget)) return; if (hasInvalidField(e.currentTarget)) return;
@@ -243,6 +262,7 @@ export default function Contact() {
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10"> <div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
<WhatsAppLink <WhatsAppLink
placement="contact_direct" placement="contact_direct"
service={directSubject}
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline" className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
> >
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]"> <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
@@ -283,6 +303,7 @@ export default function Contact() {
</div> </div>
<form <form
id={CONTACT_FORM_ID}
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate noValidate
aria-labelledby="contact-form-title" aria-labelledby="contact-form-title"
@@ -358,6 +379,17 @@ export default function Contact() {
</select> </select>
)} )}
</Field> </Field>
{/* <output> (role status): o texto muda quando uma oferta da home é escolhida, e o leitor de tela o anuncia; vazio, não ocupa espaço */}
<output className="block !mt-sp-2 font-display text-[15px] leading-6 text-white/85 empty:!mt-0">
{chosenOffer && (
<>
Você escolheu:{" "}
<strong className="font-semibold text-white">
{CONTACT_KINDS[chosenOffer]}
</strong>
</>
)}
</output>
<Field <Field
id="contact-message" id="contact-message"
label="Contexto do projeto" label="Contexto do projeto"
+18 -12
View File
@@ -6,12 +6,12 @@ import { CONTACT_KIND_EVENT } from "./contact-subject";
/** /**
* Testes de caminhos-base de ContactOfferLink (complemento de ContactOfferLink.test.tsx). * Testes de caminhos-base de ContactOfferLink (complemento de ContactOfferLink.test.tsx).
* *
* onClick (CC 6): fora da home | sem #contact | clique já tratado pelo gerenciador de âncoras | tecla modificadora ou * onClick (CC 6): fora da home | sem #contact-form | clique já tratado pelo gerenciador de âncoras | tecla modificadora ou
* botão que não é o principal | rolagem suave | rolagem em salto (movimento reduzido). Os seis caminhos já estão * botão que não é o principal | rolagem suave | rolagem em salto (movimento reduzido). Os seis caminhos já estão
* em ContactOfferLink.test.tsx; aqui entram o que faltava: * em ContactOfferLink.test.tsx; aqui entram o que faltava:
* - o gerenciador de âncoras REAL (fragment-nav.onFragmentClick) tratando o clique, em vez de um ouvinte avulso; * - o gerenciador de âncoras REAL (fragment-nav.onFragmentClick) tratando o clique, em vez de um ouvinte avulso;
* - Alt como tecla modificadora; * - Alt como tecla modificadora;
* - showContactInAddress (CC 2): o endereço já é /#contact, então não há replaceState; e o `history.state` do roteador * - showContactInAddress (CC 2): o endereço já é /#contact-form, então não há replaceState; e o `history.state` do roteador
* do Next sobrevive ao replaceState. * do Next sobrevive ao replaceState.
*/ */
@@ -52,7 +52,7 @@ beforeEach(() => {
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
replaceState = jest.spyOn(window.history, "replaceState"); replaceState = jest.spyOn(window.history, "replaceState");
form = document.createElement("section"); form = document.createElement("section");
form.id = "contact"; form.id = "contact-form";
document.body.append(form); document.body.append(form);
}); });
@@ -96,10 +96,10 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
expect(scrollIntoView).not.toHaveBeenCalled(); expect(scrollIntoView).not.toHaveBeenCalled();
expect(form).toHaveFocus(); expect(form).toHaveFocus();
expect(heard).toEqual(["sistema"]); expect(heard).toEqual(["sistema"]);
// o gerenciador abriu uma entrada de histórico (/?tipo=site#contact); a oferta só a reescreve // o gerenciador abriu uma entrada de histórico (/?tipo=site#contact-form); a oferta só a reescreve
expect(window.history.length).toBe(entries + 1); expect(window.history.length).toBe(entries + 1);
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact"); expect(window.location.hash).toBe("#contact-form");
expect(replaceState).toHaveBeenCalledTimes(1); expect(replaceState).toHaveBeenCalledTimes(1);
} finally { } finally {
stop(); stop();
@@ -107,8 +107,8 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
} }
}); });
it("path: the manager takes the click and the address is already /#contact -> no replaceState at all", () => { it("path: the manager takes the click and the address is already /#contact-form -> no replaceState at all", () => {
window.history.replaceState(null, "", "/#contact"); window.history.replaceState(null, "", "/#contact-form");
replaceState.mockClear(); replaceState.mockClear();
const stop = installAnchorManager(); const stop = installAnchorManager();
try { try {
@@ -118,7 +118,9 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
expect(scrollTo).toHaveBeenCalledTimes(1); expect(scrollTo).toHaveBeenCalledTimes(1);
expect(replaceState).not.toHaveBeenCalled(); expect(replaceState).not.toHaveBeenCalled();
expect(window.location.pathname + window.location.hash).toBe("/#contact"); expect(window.location.pathname + window.location.hash).toBe(
"/#contact-form",
);
} finally { } finally {
stop(); stop();
} }
@@ -131,9 +133,9 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
expect(fireEvent.click(link)).toBe(false); expect(fireEvent.click(link)).toBe(false);
expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(form).toHaveFocus(); expect(form).toHaveFocus();
expect(window.location.hash).toBe("#contact"); expect(window.location.hash).toBe("#contact-form");
// o endereço já é /#contact (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço // o endereço já é /#contact-form (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço
(document.activeElement as HTMLElement).blur(); (document.activeElement as HTMLElement).blur();
replaceState.mockClear(); replaceState.mockClear();
expect(fireEvent.click(link)).toBe(false); expect(fireEvent.click(link)).toBe(false);
@@ -177,13 +179,17 @@ describe("ContactOfferLink: teclas e histórico", () => {
it("path: replaceState keeps the history.state of the router (the Next router stores its tree there)", () => { it("path: replaceState keeps the history.state of the router (the Next router stores its tree there)", () => {
const routerState = { __NA: true, tree: ["", {}] }; const routerState = { __NA: true, tree: ["", {}] };
window.history.replaceState(routerState, "", "/?tipo=site#contact"); window.history.replaceState(routerState, "", "/?tipo=site#contact-form");
replaceState.mockClear(); replaceState.mockClear();
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>); render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" })); fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(replaceState).toHaveBeenCalledWith(routerState, "", "/#contact"); expect(replaceState).toHaveBeenCalledWith(
routerState,
"",
"/#contact-form",
);
expect(window.history.state).toEqual(routerState); expect(window.history.state).toEqual(routerState);
}); });
}); });
+15 -11
View File
@@ -27,7 +27,7 @@ describe("ContactOfferLink", () => {
); );
const link = screen.getByRole("link", { name: "Começar pelo sistema" }); const link = screen.getByRole("link", { name: "Começar pelo sistema" });
expect(link).toHaveAttribute("href", "/?tipo=sistema#contact"); expect(link).toHaveAttribute("href", "/?tipo=sistema#contact-form");
expect(link).toHaveClass("botao"); expect(link).toHaveClass("botao");
expect(link).toHaveAttribute("data-prefetch", "false"); expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link); fireEvent.mouseEnter(link);
@@ -88,7 +88,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
setReducedMotion(false); setReducedMotion(false);
form = document.createElement("section"); form = document.createElement("section");
form.id = "contact"; form.id = "contact-form";
document.body.append(form); document.body.append(form);
}); });
@@ -98,7 +98,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
}); });
it("cancels the navigation on the home, rolls to #contact, focuses the form and leaves the address as /#contact", () => { it("cancels the navigation on the home, rolls to #contact-form, focuses the form and leaves the address as /#contact-form", () => {
const heard: unknown[] = []; const heard: unknown[] = [];
const listener = (event: Event) => const listener = (event: Event) =>
heard.push((event as CustomEvent).detail); heard.push((event as CustomEvent).detail);
@@ -120,7 +120,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
}); });
expect(form).toHaveFocus(); expect(form).toHaveFocus();
expect(window.location.pathname + window.location.search).toBe("/"); expect(window.location.pathname + window.location.search).toBe("/");
expect(window.location.hash).toBe("#contact"); expect(window.location.hash).toBe("#contact-form");
expect(pushState).not.toHaveBeenCalled(); expect(pushState).not.toHaveBeenCalled();
} finally { } finally {
pushState.mockRestore(); pushState.mockRestore();
@@ -140,16 +140,20 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
}); });
it("drops the ?tipo= left by an earlier offer or by the arrival from another page, and keeps the other parameters", () => { it("drops the ?tipo= left by an earlier offer or by the arrival from another page, and keeps the other parameters", () => {
window.history.replaceState(null, "", "/?utm_source=ads&tipo=site#contact"); window.history.replaceState(
null,
"",
"/?utm_source=ads&tipo=site#contact-form",
);
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>); render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" })); fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(window.location.search).toBe("?utm_source=ads"); expect(window.location.search).toBe("?utm_source=ads");
expect(window.location.hash).toBe("#contact"); expect(window.location.hash).toBe("#contact-form");
}); });
it("leaves the click alone when the scroll manager already took it (no second scroll), still cleaning the address", () => { it("leaves the click alone when the scroll manager already took it (no second scroll), still cleaning the address", () => {
window.history.replaceState(null, "", "/?tipo=site#contact"); window.history.replaceState(null, "", "/?tipo=site#contact-form");
const cancel = (event: Event) => event.preventDefault(); const cancel = (event: Event) => event.preventDefault();
document.addEventListener("click", cancel, true); document.addEventListener("click", cancel, true);
try { try {
@@ -158,7 +162,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
fireEvent.click(screen.getByRole("link", { name: "Começar" })); fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollIntoView).not.toHaveBeenCalled(); expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact"); expect(window.location.hash).toBe("#contact-form");
} finally { } finally {
document.removeEventListener("click", cancel, true); document.removeEventListener("click", cancel, true);
} }
@@ -176,19 +180,19 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
expect(window.location.hash).toBe(""); expect(window.location.hash).toBe("");
}); });
it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact", () => { it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact-form", () => {
window.history.replaceState(null, "", "/privacidade"); window.history.replaceState(null, "", "/privacidade");
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>); render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" }); const link = screen.getByRole("link", { name: "Começar" });
expect(link).toHaveAttribute("href", "/?tipo=revisao#contact"); expect(link).toHaveAttribute("href", "/?tipo=revisao#contact-form");
expect(fireEvent.click(link)).toBe(true); expect(fireEvent.click(link)).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled(); expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.pathname).toBe("/privacidade"); expect(window.location.pathname).toBe("/privacidade");
expect(window.location.hash).toBe(""); expect(window.location.hash).toBe("");
}); });
it("falls back to the Link navigation when the page has no #contact", () => { it("falls back to the Link navigation when the page has no #contact-form", () => {
form.remove(); form.remove();
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>); render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
+10 -11
View File
@@ -8,6 +8,7 @@ import {
isPlainPrimaryClick, isPlainPrimaryClick,
} from "./cinematic/fragment-nav"; } from "./cinematic/fragment-nav";
import { import {
CONTACT_FORM_HASH,
CONTACT_KIND_EVENT, CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM, CONTACT_KIND_PARAM,
type ContactKind, type ContactKind,
@@ -15,17 +16,15 @@ import {
} from "./contact-subject"; } from "./contact-subject";
import HoverPrefetchLink from "./HoverPrefetchLink"; import HoverPrefetchLink from "./HoverPrefetchLink";
const CONTACT_HASH = "#contact";
/** /**
* Deixa o endereço da home como o do link do cabeçalho (`/#contact`): com o fragmento do formulário e sem o `?tipo=` * Deixa o endereço da home com o fragmento do cartão do formulário (`/#contact-form`) e sem o `?tipo=` que uma oferta
* que uma oferta anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não * anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não uma navegação do
* uma navegação do Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página. * Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
*/ */
function showContactInAddress() { function showContactInAddress() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete(CONTACT_KIND_PARAM); url.searchParams.delete(CONTACT_KIND_PARAM);
url.hash = CONTACT_HASH; url.hash = CONTACT_FORM_HASH;
const next = `${url.pathname}${url.search}${url.hash}`; const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) if (next !== current)
@@ -33,14 +32,14 @@ function showContactInAddress() {
} }
/** /**
* Botão de uma oferta da home: leva ao formulário de contato com o tipo de projeto já escolhido. * Botão de uma oferta da home: leva ao cartão do formulário de contato (`#contact-form`, com o campo do tipo de projeto
* Na própria home o tipo segue por evento e a página rola até `#contact` (sem navegar pelo Next); o `href` com * à vista, também no celular) com o tipo já escolhido. Na própria home o tipo segue por evento e a página rola até o
* `?tipo=` cobre quem chega de outra página ou abre o link em outra aba. * cartão (sem navegar pelo Next); o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
* *
* O normal é o gerenciador de âncoras (fragment-nav + useCinematicScroll) tratar o clique antes deste código, do * O normal é o gerenciador de âncoras (fragment-nav + useCinematicScroll) tratar o clique antes deste código, do
* mesmo jeito que trata o link do cabeçalho: rola (com movimento reduzido, em salto), dá o foco ao formulário e * mesmo jeito que trata o link do cabeçalho: rola (com movimento reduzido, em salto), dá o foco ao formulário e
* cancela o clique. Quando ele não o trata (ainda não montou, por exemplo), este botão cancela a navegação, rola e dá * cancela o clique. Quando ele não o trata (ainda não montou, por exemplo), este botão cancela a navegação, rola e dá
* o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact`. * o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact-form`.
*/ */
export default function ContactOfferLink({ export default function ContactOfferLink({
kind, kind,
@@ -56,7 +55,7 @@ export default function ContactOfferLink({
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }), new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
); );
if (window.location.pathname !== "/") return; if (window.location.pathname !== "/") return;
const form = elementForHash(CONTACT_HASH); const form = elementForHash(CONTACT_FORM_HASH);
if (!form) return; if (!form) return;
if (event.defaultPrevented) { if (event.defaultPrevented) {
showContactInAddress(); showContactInAddress();
+17 -1
View File
@@ -1,6 +1,8 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { resetNavigation, setNavigation } from "@/__mocks__/next/navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp"; import FloatingWhatsApp from "./FloatingWhatsApp";
import GoogleAnalytics from "./GoogleAnalytics"; import GoogleAnalytics from "./GoogleAnalytics";
@@ -50,6 +52,7 @@ const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" }); screen.queryByRole("link", { name: "Conversar no WhatsApp" });
beforeEach(() => { beforeEach(() => {
resetNavigation();
instances.length = 0; instances.length = 0;
global.IntersectionObserver = global.IntersectionObserver =
FakeObserver as unknown as typeof IntersectionObserver; FakeObserver as unknown as typeof IntersectionObserver;
@@ -191,7 +194,20 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => {
expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4"); expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4");
}); });
it("path: click reports 'Contato' as the service because the floating link has no subject", () => { it("path: click on the home reports the home subject as the service (the central navigation mock opens on '/')", () => {
render(<FloatingWhatsApp />);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({ service: HOME_SUBJECT, placement: "floating" }),
);
});
it("path: click on a page without a subject reports 'Contato' as the service", () => {
setNavigation({ pathname: "/privacidade" });
render(<FloatingWhatsApp />); render(<FloatingWhatsApp />);
fireEvent.click(floatingLink() as HTMLElement, { button: 0 }); fireEvent.click(floatingLink() as HTMLElement, { button: 0 });
@@ -8,10 +8,9 @@ import FloatingWhatsApp from "./FloatingWhatsApp";
* do hero e do contato (que só existem na home) recomeça a cada troca de página. * 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 = { const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" }, // o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}."
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
cases: { "sev-modaintima": "SEV Moda Íntima" }, cases: { "sev-modaintima": "SEV Moda Íntima" },
}; };
@@ -57,7 +56,7 @@ describe("message by route", () => {
it.each([ it.each([
[ [
"/servicos/criacao-de-sites", "/servicos/criacao-de-sites",
"Quero conversar sobre Sites profissionais.", "Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
], ],
[ [
"/cases/sev-modaintima", "/cases/sev-modaintima",
@@ -66,7 +65,7 @@ describe("message by route", () => {
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."], ["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
// a mesma frase do botão do hero e do cabeçalho de cada página // a mesma frase do botão do hero e do cabeçalho de cada página
["/", "Quero conversar sobre um site ou sistema para a minha empresa."], ["/", "Quero conversar sobre um site ou sistema para a minha empresa."],
["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."], ["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."],
[ [
"/cases", "/cases",
"Quero conversar sobre um projeto parecido com os cases do portfólio.", "Quero conversar sobre um projeto parecido com os cases do portfólio.",
@@ -132,7 +131,7 @@ describe("page changes", () => {
rerender(<FloatingWhatsApp contexts={contexts} />); rerender(<FloatingWhatsApp contexts={contexts} />);
expect(instances[0].disconnect).toHaveBeenCalled(); expect(instances[0].disconnect).toHaveBeenCalled();
expect(messageOf(floatingLink())).toContain( expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre Sites profissionais.", "Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
); );
}); });
}); });
+3 -1
View File
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp"; import FloatingWhatsApp from "./FloatingWhatsApp";
const CONSENT_KEY = "evandro.analytics-consent.v1"; const CONSENT_KEY = "evandro.analytics-consent.v1";
@@ -79,8 +80,9 @@ it("stays hidden until the observer reports, then shows a 56px WhatsApp shortcut
expect(link).toHaveAttribute("rel", "noopener noreferrer"); expect(link).toHaveAttribute("rel", "noopener noreferrer");
const url = new URL(link?.getAttribute("href") ?? ""); const url = new URL(link?.getAttribute("href") ?? "");
expect(url.origin).toBe("https://wa.me"); expect(url.origin).toBe("https://wa.me");
// o dublê central de next/navigation abre na home ("/"): o botão fala do assunto da home
expect(url.searchParams.get("text")).toContain( expect(url.searchParams.get("text")).toContain(
"Quero conversar sobre um projeto.", `Quero conversar sobre ${HOME_SUBJECT}.`,
); );
// alvo de 56 px, canto inferior direito, só abaixo de 768 px // alvo de 56 px, canto inferior direito, só abaixo de 768 px
expect(link).toHaveClass("h-14", "w-14"); expect(link).toHaveClass("h-14", "w-14");
+27 -1
View File
@@ -73,7 +73,9 @@ describe("Footer contact and navigation", () => {
"/cases", "/cases",
); );
expect( expect(
within(more).getByRole("link", { name: "Revisão de app feito com IA" }), within(more).getByRole("link", {
name: "Revisão de sistema que já existe",
}),
).toHaveAttribute("href", "/ia"); ).toHaveAttribute("href", "/ia");
expect( expect(
within(more).getByRole("link", { name: "Privacidade" }), within(more).getByRole("link", { name: "Privacidade" }),
@@ -143,6 +145,30 @@ describe("Footer social links", () => {
expect(instagram).toHaveAttribute("rel", "noopener noreferrer"); expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
}); });
it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => {
render(<Footer />);
expect(
screen.getByRole("link", { name: "Revisão de sistema que já existe" }),
).toHaveAttribute("href", "/ia");
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
});
it("adds the height of the cookie bar to the bottom padding, so the logo and the copyright are not under it at the end of the page", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer") as HTMLElement;
// com a barra fechada a variável não existe: o 0px do fallback deixa o rodapé como era
expect(footer.className).toContain(
"pb-[calc(var(--sp-10)+var(--cookie-bar-height,0px))]",
);
// no celular o botão flutuante do WhatsApp (72 px) também continua à parte
expect(footer.className).toContain(
"max-md:pb-[calc(var(--sp-10)+72px+var(--cookie-bar-height,0px))]",
);
expect(footer.className).not.toMatch(/(?:^|\s)py-sp-10(?:\s|$)/);
});
it("pre-fetches the internal links only when the visitor reaches them, never by viewport (services, cases, /ia, privacy, résumé, home)", () => { it("pre-fetches the internal links only when the visitor reaches them, never by viewport (services, cases, /ia, privacy, résumé, home)", () => {
render(<Footer serviceLinks={serviceHubLinks} />); render(<Footer serviceLinks={serviceHubLinks} />);
+6 -3
View File
@@ -5,6 +5,7 @@ import {
trackEmailClick, trackEmailClick,
trackSocialClick, trackSocialClick,
} from "@/lib/analytics"; } from "@/lib/analytics";
import { offerLabels } from "@/lib/offer-labels";
import { import {
CONTACT_EMAIL, CONTACT_EMAIL,
INSTAGRAM_URL, INSTAGRAM_URL,
@@ -31,8 +32,10 @@ export default function Footer({
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
// 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 // 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)]"> // A barra de cookies (fixa no pé da tela) cobriria o logo e o © no fim da página: o rodapé também sobe a altura dela
// (--cookie-bar-height, publicada por GoogleAnalytics enquanto a barra está aberta; sem barra, 0).
<footer className="border-t border-[var(--border)] bg-[var(--paper)] px-[clamp(20px,6vw,64px)] pt-sp-10 pb-[calc(var(--sp-10)+var(--cookie-bar-height,0px))] text-[var(--ink)] max-md:pb-[calc(var(--sp-10)+72px+var(--cookie-bar-height,0px))]">
<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">
@@ -115,7 +118,7 @@ export default function Footer({
</li> </li>
<li> <li>
<HoverPrefetchLink href="/ia" className={linkClassName}> <HoverPrefetchLink href="/ia" className={linkClassName}>
Revisão de app feito com IA {offerLabels.revisao.name}
</HoverPrefetchLink> </HoverPrefetchLink>
</li> </li>
<li> <li>
+28 -5
View File
@@ -75,15 +75,38 @@ afterEach(() => {
}); });
describe("GoogleAnalytics: modo e teclas", () => { describe("GoogleAnalytics: modo e teclas", () => {
it("path: outside production -> renders nothing and never touches analytics", async () => { it("path: outside production -> the cookie bar and the choice are still there (dev and production behave the same), but the gtag script is never loaded", async () => {
jest.replaceProperty(process.env, "NODE_ENV", "development"); jest.replaceProperty(process.env, "NODE_ENV", "development");
const user = userEvent.setup();
const { container } = render(<GoogleAnalytics />); render(<GoogleAnalytics />);
await act(async () => {});
expect(container).toBeEmptyDOMElement(); // a barra (e o foco por Tab) é a mesma da produção
const dialog = await screen.findByRole("dialog", {
name: "Cookies e privacidade",
});
expect(dialog).toBeVisible();
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).not.toBe("");
await user.click(screen.getByRole("button", { name: "Aceitar" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
expect(analyticsWindow.dataLayer).toBeUndefined(); // mesmo com o consentimento, fora da produção o gtag não é baixado
await act(async () => {});
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
expect(document.getElementById("google-analytics-script")).toBeNull();
});
it("path: the same consent in production loads the gtag script (the only thing tied to NODE_ENV)", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
expect(
await screen.findByTestId("google-analytics-script"),
).toBeInTheDocument();
}); });
it("path: Escape on the first-visit bar -> bar stays and no choice is stored", async () => { it("path: Escape on the first-visit bar -> bar stays and no choice is stored", async () => {
+5 -2
View File
@@ -212,7 +212,7 @@ function GoogleAnalyticsConsent() {
return ( return (
<> <>
{consent === "granted" && ( {consent === "granted" && process.env.NODE_ENV === "production" && (
<Script <Script
id="google-analytics-script" id="google-analytics-script"
data-testid="google-analytics-script" data-testid="google-analytics-script"
@@ -286,7 +286,10 @@ function GoogleAnalyticsConsent() {
); );
} }
/**
* A barra de cookies e o consentimento aparecem em qualquer ambiente: no desenvolvimento a página fica igual à da
* produção (ordem do Tab, altura da barra, rodapé). Só o carregamento do gtag (o <Script> acima) fica preso à produção.
*/
export default function GoogleAnalytics() { export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") return null;
return <GoogleAnalyticsConsent />; return <GoogleAnalyticsConsent />;
} }
-1
View File
@@ -9,7 +9,6 @@ import { Header } from "./Header";
* com Esc (ouvido no document), devolvendo o foco ao botão do menu. * com Esc (ouvido no document), devolvendo o foco ao botão do menu.
*/ */
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({ jest.mock("next/link", () => ({
__esModule: true, __esModule: true,
default: ({ default: ({
-1
View File
@@ -7,7 +7,6 @@ import { Header } from "./Header";
* as teclas do menu do celular que não o fecham e a rota ausente (fora do roteador). * 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", () => ({ jest.mock("next/link", () => ({
__esModule: true, __esModule: true,
default: ({ default: ({
+5 -4
View File
@@ -5,7 +5,6 @@ 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", () => ({ jest.mock("next/link", () => ({
__esModule: true, __esModule: true,
default: ({ default: ({
@@ -23,7 +22,8 @@ jest.mock("next/link", () => ({
})); }));
const contexts: WhatsAppContexts = { const contexts: WhatsAppContexts = {
services: { "criacao-de-sites": "Sites profissionais" }, // o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}."
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
cases: { "sev-modaintima": "SEV Moda Íntima" }, cases: { "sev-modaintima": "SEV Moda Íntima" },
}; };
@@ -233,7 +233,7 @@ describe("Header", () => {
"/cases", "/cases",
"Quero conversar sobre um projeto parecido com os cases do portfólio.", "Quero conversar sobre um projeto parecido com os cases do portfólio.",
], ],
["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."], ["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."],
[ [
"/curriculo", "/curriculo",
"Quero conversar sobre um projeto, depois de ver o seu currículo.", "Quero conversar sobre um projeto, depois de ver o seu currículo.",
@@ -263,8 +263,9 @@ describe("Header", () => {
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/); expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank"); expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain( expect(whatsAppHref(contact)).toContain(
"Quero conversar sobre Sites profissionais.", "Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
); );
expect(whatsAppHref(contact)).not.toContain("sobre Sites");
}); });
it("opens WhatsApp with the case in the header button on a case page, also from the mobile menu", () => { it("opens WhatsApp with the case in the header button on a case page, also from the mobile menu", () => {
+2 -2
View File
@@ -152,9 +152,9 @@ describe("Hero", () => {
image.getAttribute("src") ?? "", image.getAttribute("src") ?? "",
"http://localhost", "http://localhost",
).searchParams.get("url"); ).searchParams.get("url");
expect(original(images[0])).toBe("/videos/sev-modaintima/desktop.webp"); expect(original(images[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(images[0]).toHaveAccessibleName( expect(images[0]).toHaveAccessibleName(
"Site da SEV Moda Íntima aberto no computador", "Site da Lavação 047 aberto no computador",
); );
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp"); expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
}); });
+22 -4
View File
@@ -17,7 +17,7 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img"); const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
expect(desktop).toHaveLength(2); expect(desktop).toHaveLength(2);
expect(original(desktop[0])).toBe("/videos/sev-modaintima/desktop.webp"); expect(original(desktop[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp"); expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra // 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
expect(desktop[0]).toHaveAttribute( expect(desktop[0]).toHaveAttribute(
@@ -29,12 +29,30 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
"(min-width: 1400px) 136px, 10vw", "(min-width: 1400px) 136px, 10vw",
); );
// o cartão do celular usa o pôster largo da SEV, na largura da coluna (cerca de 350 px num celular de 390 px) // o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
const small = screen const small = screen
.getByTestId("hero-work-mobile") .getByTestId("hero-work-mobile")
.querySelector("img") as HTMLImageElement; .querySelector("img") as HTMLImageElement;
expect(original(small)).toBe("/videos/sev-modaintima/desktop.webp"); expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
expect(small).toHaveAttribute("sizes", "(min-width: 1024px) 1px, 100vw"); expect(small).toHaveAttribute(
"sizes",
"(min-width: 1024px) 1px, calc(100vw - 48px)",
);
expect(small).toHaveAttribute("width", "390");
expect(small).toHaveAttribute("height", "844");
});
it("shows the headline of the SEV poster in the 72px strip of the mobile card: the position moves the strip to the line 'Nova Coleção' instead of the top of the poster", () => {
render(<HeroWork />);
const small = screen
.getByTestId("hero-work-mobile")
.querySelector("img") as HTMLImageElement;
expect(small).toHaveClass("object-cover", "object-[50%_57%]");
expect(small).not.toHaveClass("object-top");
// a faixa continua pequena (o título da página segue como o maior elemento da dobra)
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
}); });
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => { it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
+17 -10
View File
@@ -7,23 +7,30 @@ import { PhoneFrame } from "./showcase/PhoneFrame";
/** 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 = "sev-modaintima"; const DESKTOP_PROJECT = "lavacao-047";
const DESKTOP_PROJECT_TITLE = "Lavação 047";
const MOBILE_PROJECT = "as-servicos"; const MOBILE_PROJECT = "as-servicos";
/** No celular o cartão mostra o site da SEV, o mesmo projeto do navegador ao lado do título no computador, e leva ao case. */ /** No celular o cartão mostra o site da SEV e leva ao case; o navegador da tela larga, ao lado do título, mostra o da Lavação 047. */
const SMALL_SCREEN_PROJECT = "sev-modaintima"; const SMALL_SCREEN_PROJECT = "sev-modaintima";
const SMALL_SCREEN_TITLE = "SEV Moda Íntima"; const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`; const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
/** /**
* Altura da imagem do cartão de celular (px): a largura é a da coluna (cerca de 350 px num celular de 390 px). Com 72 px * Altura da imagem do cartão de celular (px): a largura é a da coluna (cerca de 342 px num celular de 390 px). Com 72 px
* a área da imagem (cerca de 25 mil px²) fica abaixo da do H1 (cerca de 29 mil px² a 390 px): com 96 px ela era maior, e * a área da imagem (cerca de 25 mil px²) fica abaixo da do H1 (cerca de 29 mil px² a 390 px): com 96 px ela era maior, e
* numa rede lenta a imagem, que chega depois, passava a ser o LCP no lugar do título (medido com rede e CPU limitadas). * numa rede lenta a imagem, que chega depois, passava a ser o LCP no lugar do título (medido com rede e CPU limitadas).
*/ */
const SMALL_CARD_IMAGE_HEIGHT = 72; const SMALL_CARD_IMAGE_HEIGHT = 72;
/**
* Faixa de 72 px de altura: o pôster do computador (16:10) cortado nela mostrava só uma fatia do topo, e o título
* "Nova Coleção" saía pela metade. O pôster do celular (390x844, o mesmo site) tem o título em letras maiores: o
* `object-position` leva a faixa até a linha "Nova Coleção", que cabe inteira nela.
*/
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
/** /**
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da SEV * Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da
* Moda Íntima no navegador (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo * Lavação 047 no navegador (a barra de endereço só mostra domínio próprio; o da Lavação é de hospedagem, então ela
* da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável * fica com "pré-visualização") e o do aplicativo da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao * (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era. * case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela * Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
@@ -34,7 +41,7 @@ const SMALL_CARD_IMAGE_HEIGHT = 72;
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");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile"); const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "desktop"); const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
return ( return (
<> <>
@@ -46,7 +53,7 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */} {/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
<Image <Image
src={desktop.poster} src={desktop.poster}
alt="Site da SEV Moda Íntima aberto no computador" alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
width={desktop.width} width={desktop.width}
height={desktop.height} height={desktop.height}
sizes="(min-width: 1400px) 500px, 36vw" sizes="(min-width: 1400px) 500px, 36vw"
@@ -87,10 +94,10 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
alt="" alt=""
width={smallCard.width} width={smallCard.width}
height={smallCard.height} height={smallCard.height}
sizes="(min-width: 1024px) 1px, 100vw" sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy" loading="lazy"
fetchPriority="low" fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top" className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/> />
</span> </span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]"> <span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
@@ -13,7 +13,6 @@ import HoverPrefetchLink from "./HoverPrefetchLink";
* O dublê de next/navigation sem `usePathname` está em HoverPrefetchLink.nopathname.paths.test.tsx. * O dublê de next/navigation sem `usePathname` está em HoverPrefetchLink.nopathname.paths.test.tsx.
*/ */
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({ jest.mock("next/link", () => ({
__esModule: true, __esModule: true,
default: ({ default: ({
@@ -2,8 +2,6 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink"; import HoverPrefetchLink from "./HoverPrefetchLink";
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({ jest.mock("next/link", () => ({
__esModule: true, __esModule: true,
default: ({ default: ({
+145
View File
@@ -0,0 +1,145 @@
import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Projects from "./Projects";
/**
* "Ver os cases" na coluna dos botões da vitrine: na vitrine fixa o conjunto de aparelhos (`.ds-reel-set`, com a legenda
* e os botões no canto esquerdo dele) fica centrado no palco, e esse canto muda com a altura da janela. Projects mede o
* canto e dá ao bloco do link o mesmo recuo; sem vitrine fixa (celular, movimento reduzido) o recuo é zero.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch: _prefetch,
...props
}: { children?: ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => <a {...props}>{children}</a>,
}));
jest.mock("./Section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./showcase/ShowcaseProvider", () => ({
ShowcaseProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
let pinned = true;
jest.mock("./showcase/ProjectReel", () => ({
ProjectReel: () => (pinned ? <div className="ds-reel-set" /> : <ul />),
}));
/** Os cantos esquerdos que a página teria: o do bloco do link e o do conjunto de aparelhos. */
const lefts = { exit: 100, set: 503 };
class FakeResizeObserver {
static instances: FakeResizeObserver[] = [];
observed = new Set<Element>();
constructor(private readonly callback: () => void) {
FakeResizeObserver.instances.push(this);
}
observe(target: Element) {
this.observed.add(target);
}
unobserve(target: Element) {
this.observed.delete(target);
}
disconnect() {
this.observed.clear();
}
fire() {
this.callback();
}
}
const originalRect = Element.prototype.getBoundingClientRect;
const originalObserver = global.ResizeObserver;
beforeEach(() => {
pinned = true;
lefts.exit = 100;
lefts.set = 503;
FakeResizeObserver.instances = [];
global.ResizeObserver =
FakeResizeObserver as unknown as typeof ResizeObserver;
Element.prototype.getBoundingClientRect = function rect(this: Element) {
const left = this.classList.contains("ds-reel-set")
? lefts.set
: this.getAttribute("data-reel-stagger") === "left"
? lefts.exit
: 0;
return {
left,
right: left,
top: 0,
bottom: 0,
width: 0,
height: 0,
} as DOMRect;
};
});
afterEach(() => {
Element.prototype.getBoundingClientRect = originalRect;
global.ResizeObserver = originalObserver;
});
const exitBlock = () =>
screen.getByRole("link", { name: "Ver os cases" })
.parentElement as HTMLElement;
describe("Projects: saída da vitrine", () => {
it("indents the link to the left corner of the pinned reel, where the caption and the buttons start", () => {
render(<Projects projects={[]} />);
expect(exitBlock().style.paddingLeft).toBe("403px");
});
it("follows the reel when the window changes size (the stage measures itself), and when the reel is observed", () => {
render(<Projects projects={[]} />);
expect(exitBlock().style.paddingLeft).toBe("403px");
lefts.set = 567;
act(() => {
for (const observer of FakeResizeObserver.instances) observer.fire();
});
expect(exitBlock().style.paddingLeft).toBe("467px");
// observa a seção (largura da janela) e o conjunto de aparelhos (a barra de cookies muda a altura do palco)
const observed = FakeResizeObserver.instances.flatMap((observer) => [
...observer.observed,
]);
expect(observed.some((element) => element.tagName === "SECTION")).toBe(
true,
);
expect(
observed.some((element) => element.classList.contains("ds-reel-set")),
).toBe(true);
});
it("keeps the link at the start of the column without a pinned reel (phone, reduced motion, short window)", () => {
pinned = false;
render(<Projects projects={[]} />);
expect(exitBlock().style.paddingLeft).toBe("");
});
it("never indents to the left of its own column, and works where there is no ResizeObserver", () => {
lefts.set = 40;
global.ResizeObserver = undefined as unknown as typeof ResizeObserver;
render(<Projects projects={[]} />);
expect(exitBlock().style.paddingLeft).toBe("");
});
});
+64 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { type RefObject, useEffect, useRef, useState } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data"; import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { secondaryButtonDark } from "./button-styles"; import { secondaryButtonDark } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink"; import HoverPrefetchLink from "./HoverPrefetchLink";
@@ -7,12 +8,69 @@ import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel"; import { ProjectReel } from "./showcase/ProjectReel";
import { ShowcaseProvider } from "./showcase/ShowcaseProvider"; import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
/**
* Quanto "Ver os cases" anda para a direita para ficar na coluna da legenda e dos botões da vitrine. Na vitrine fixa
* (a partir de 1024 px) o conjunto de aparelhos, com a legenda e os botões no canto esquerdo dele, fica centrado no
* palco, e esse canto muda com a altura da janela (o palco mede a si mesmo): só medindo dá para alinhar o link a ele.
* Sem vitrine fixa (celular, movimento reduzido, janela baixa) não há conjunto e o link fica no começo da coluna.
*/
function useReelColumnOffset(exitRef: RefObject<HTMLElement | null>) {
const [offset, setOffset] = useState(0);
useEffect(() => {
const exit = exitRef.current;
const section = exit?.closest("section");
if (!exit || !section) return;
let observedSet: Element | null = null;
const refresh = () => {
const set = section.querySelector(".ds-reel-set");
if (set !== observedSet) {
if (observedSet) resizes?.unobserve(observedSet);
if (set) resizes?.observe(set);
observedSet = set;
}
// o padding do próprio bloco não muda o `left` dele: a medida é só a distância até o canto do conjunto
setOffset(
set
? Math.max(
0,
Math.round(
set.getBoundingClientRect().left -
exit.getBoundingClientRect().left,
),
)
: 0,
);
};
const resizes =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver(refresh);
refresh();
resizes?.observe(section);
// a vitrine fixa chega depois (pacote sob demanda) e sai quando a janela deixa de comportá-la
const mutations = new MutationObserver(refresh);
mutations.observe(section, { childList: true, subtree: true });
return () => {
resizes?.disconnect();
mutations.disconnect();
};
}, [exitRef]);
return offset;
}
/** Vitrine única da home: recebe só projetos de cliente (o filtro vem de app/page.tsx). */ /** Vitrine única da home: recebe só projetos de cliente (o filtro vem de app/page.tsx). */
export default function Projects({ export default function Projects({
projects, projects,
}: { }: {
projects: PortfolioProjectView[]; projects: PortfolioProjectView[];
}) { }) {
const exitRef = useRef<HTMLDivElement>(null);
const offset = useReelColumnOffset(exitRef);
return ( return (
<Section <Section
id="projects" id="projects"
@@ -26,7 +84,12 @@ export default function Projects({
<ProjectReel /> <ProjectReel />
</ShowcaseProvider> </ShowcaseProvider>
<div className="mt-sp-8" data-reel-stagger="left"> <div
ref={exitRef}
className="mt-sp-8"
data-reel-stagger="left"
style={offset ? { paddingLeft: offset } : undefined}
>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}> <HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver os cases Ver os cases
</HoverPrefetchLink> </HoverPrefetchLink>
+104 -50
View File
@@ -1,11 +1,13 @@
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { serviceOffers } from "@/lib/site-content";
import { primaryButton } from "./button-styles"; import { primaryButton } from "./button-styles";
import { WorkModalities } from "./WorkModalities"; import { WorkModalities } from "./WorkModalities";
/** /**
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do * Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (miniatura do case, botão que abre o * mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (nome e "O que você recebe" vindos de
* formulário com o tipo escolhido), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas. * `serviceOffers`, foto do case ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" uma
* vez só depois dos cartões), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css). * Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
*/ */
@@ -86,7 +88,7 @@ describe("WorkModalities: movimento", () => {
}); });
describe("WorkModalities: as três ofertas", () => { describe("WorkModalities: as três ofertas", () => {
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order", () => { it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => {
const { container } = render(<WorkModalities />); const { container } = render(<WorkModalities />);
expect( expect(
@@ -97,19 +99,20 @@ describe("WorkModalities: as três ofertas", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
const articles = Array.from(container.querySelectorAll("article")); const articles = Array.from(container.querySelectorAll("article"));
expect(articles).toHaveLength(3); expect(articles).toHaveLength(3);
expect( const titles = articles.map(
articles.map( (article) =>
(article) => within(article).getByRole("heading", { level: 3 }).textContent,
within(article).getByRole("heading", { level: 3 }).textContent, );
), expect(titles).toEqual([
).toEqual([
"Site que traz pedidos pelo WhatsApp", "Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha", "Sistema para tirar a operação da planilha",
"Revisão de sistema que já existe, inclusive feito com IA", "Revisão de sistema que já existe",
]); ]);
// a fonte é uma só, a mesma de /servicos
expect(titles).toEqual(serviceOffers.map(({ name }) => name));
}); });
it("path: the site offer cites the Lavação 047 and delivers a catalog or price table, a ready WhatsApp message, a panel to edit prices and photos and the publication", () => { it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => {
const { container } = render(<WorkModalities />); const { container } = render(<WorkModalities />);
const [site] = Array.from(container.querySelectorAll("article")); const [site] = Array.from(container.querySelectorAll("article"));
@@ -118,15 +121,10 @@ describe("WorkModalities: as três ofertas", () => {
within(site) within(site)
.getAllByRole("listitem") .getAllByRole("listitem")
.map((item) => item.textContent), .map((item) => item.textContent),
).toEqual([ ).toEqual(serviceOffers[0].deliverables);
"Catálogo ou tabela de preços",
"Mensagem pronta no WhatsApp",
"Painel para editar preços e fotos",
"Publicação do site",
]);
}); });
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row)", () => { it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row); the other two have an illustration of their own", () => {
const { container } = render(<WorkModalities />); const { container } = render(<WorkModalities />);
const [site, system, review] = Array.from( const [site, system, review] = Array.from(
@@ -143,43 +141,91 @@ describe("WorkModalities: as três ofertas", () => {
); );
expect(siteImage).toHaveAttribute("loading", "lazy"); expect(siteImage).toHaveAttribute("loading", "lazy");
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema // a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
expect(within(system).queryByRole("img")).toBeNull();
expect(system.querySelector("img")).toBeNull(); expect(system.querySelector("img")).toBeNull();
expect(review.querySelector("img")).toBeNull();
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp"); expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
expect(within(review).queryByRole("img")).toBeNull();
}); });
it("path: the system and the review offers have a card with no photo, only an icon (decorative), and the deliverables below", () => { it("path: the system and the review cards have no black box with an icon: each has an illustration in the same box as the site photo", () => {
const { container } = render(<WorkModalities />);
expect(screen.queryByTestId("offer-icon")).toBeNull();
const boxes = ["site", "sistema", "revisao"].map((id) =>
screen.getByTestId(`offer-art-${id}`),
);
// as três caixas têm as mesmas classes, então o mesmo tamanho
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
for (const box of boxes) {
expect(box.className).toContain("aspect-[16/10]");
expect(box.className).toContain("md:aspect-[21/9]");
expect(box.className).toContain("lg:aspect-[16/10]");
expect(box.firstElementChild).not.toBeNull();
}
expect(container.querySelectorAll("article")).toHaveLength(3);
});
it("path: the system illustration is a sheet -> arrow -> dashboard scheme in SVG, with three indicators, no text and no numbers", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-sistema");
const scheme = within(art).getByRole("img");
expect(scheme.tagName.toLowerCase()).toBe("svg");
expect(scheme).toHaveAccessibleName(
"Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras",
);
expect(scheme.getAttribute("viewBox")).toBe("0 0 240 150");
expect(scheme.querySelector('[data-art="planilha"]')).not.toBeNull();
expect(scheme.querySelector('[data-art="seta"]')).not.toBeNull();
const panel = scheme.querySelector('[data-art="painel"]') as Element;
expect(panel).not.toBeNull();
// três indicadores genéricos: um quadro com um traço de rótulo e um bloco de valor cada, sem dígito algum
expect(panel.querySelectorAll("g").length).toBe(3);
expect(scheme.querySelector("text")).toBeNull();
expect(art.textContent).toBe("");
// nada de cor de destaque: o limão é só do contato
expect(art.innerHTML).not.toMatch(/ccff00|var\(--slash\)/i);
});
it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-revisao");
expect(within(art).getByRole("img")).toHaveAccessibleName(
"Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média",
);
expect(within(art).getByText("Exemplo ilustrativo")).toBeInTheDocument();
const findings = Array.from(art.querySelectorAll("li")).map(
(item) => item.textContent,
);
expect(findings).toEqual([
"Dados expostosAlta",
"Chave expostaAlta",
"Sem backupMédia",
]);
// a gravidade está no texto do selo, não só na cor
for (const severity of ["Alta", "Média"]) {
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
}
// o texto da ilustração também tem 15 px, como o resto da seção
expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/);
});
it("path: the deliverables of the system and of the review come from serviceOffers, the same lists of /servicos", () => {
const { container } = render(<WorkModalities />); const { container } = render(<WorkModalities />);
const [, system, review] = Array.from( const [, system, review] = Array.from(
container.querySelectorAll("article"), container.querySelectorAll("article"),
); );
for (const card of [system, review]) {
const icon = within(card).getByTestId("offer-icon");
expect(icon.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
expect(icon.textContent).toBe("");
}
expect( expect(
within(system) within(system)
.getAllByRole("listitem") .getAllByRole("listitem")
.map((item) => item.textContent), .map((item) => item.textContent),
).toEqual([ ).toEqual(serviceOffers[1].deliverables);
"Cadastro de clientes e produtos",
"Pedidos e acompanhamento",
"Relatórios",
"Acesso por perfil",
]);
expect( expect(
within(review) within(review)
.getAllByRole("listitem") .getAllByRole("listitem")
.map((item) => item.textContent), .map((item) => item.textContent),
).toEqual([ ).toEqual(serviceOffers[2].deliverables);
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]);
}); });
it("path: every card says what you receive, with three or four items, once", () => { it("path: every card says what you receive, with three or four items, once", () => {
@@ -195,27 +241,35 @@ describe("WorkModalities: as três ofertas", () => {
} }
}); });
it("path: every card has its first step: a first conversation and, after it, the scope and the proposal in writing", () => { it("path: the first step shows once, below the three cards: a first conversation and, after it, the scope and the proposal in writing", () => {
const { container } = render(<WorkModalities />); const { container } = render(<WorkModalities />);
const cards = Array.from(container.querySelectorAll("article")); for (const card of container.querySelectorAll("article")) {
for (const card of cards) { expect(within(card).queryByText("Primeiro passo")).toBeNull();
expect(within(card).getAllByText("Primeiro passo")).toHaveLength(1);
expect(card.textContent).toMatch(
/Primeira conversa para entender .+\. Depois, escopo e proposta por escrito\./,
);
} }
expect(screen.getAllByText("Primeiro passo")).toHaveLength(3); expect(screen.getAllByText("Primeiro passo")).toHaveLength(1);
const label = screen.getByText("Primeiro passo");
expect(label.closest("article")).toBeNull();
// fica depois da grade de cartões
const grid = container.querySelector(".grid") as HTMLElement;
expect(
grid.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(label.parentElement?.textContent).toMatch(
/^Primeiro passoPrimeira conversa para entender .+\. Depois, escopo e proposta por escrito\.$/,
);
// sem prazo, sem preço
expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i);
}); });
it("path: each button opens the contact form with the project type already chosen, all three with the same filled style", () => { it("path: each button opens the contact form card with the project type already chosen, all three with the same filled style", () => {
const { container } = render(<WorkModalities />); const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article")); const articles = Array.from(container.querySelectorAll("article"));
const buttons = [ const buttons = [
["Começar pelo site", "/?tipo=site#contact"], ["Começar pelo site", "/?tipo=site#contact-form"],
["Começar pelo sistema", "/?tipo=sistema#contact"], ["Começar pelo sistema", "/?tipo=sistema#contact-form"],
["Começar pela revisão", "/?tipo=revisao#contact"], ["Começar pela revisão", "/?tipo=revisao#contact-form"],
] as const; ] as const;
buttons.forEach(([name, href], index) => { buttons.forEach(([name, href], index) => {
const button = within(articles[index]).getByRole("link", { name }); const button = within(articles[index]).getByRole("link", { name });
+239 -102
View File
@@ -1,99 +1,57 @@
import { LayoutDashboard, type LucideIcon, ShieldCheck } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase"; import { getShowcaseAssets } from "@/lib/project-showcase";
import {
generalFirstStep,
type ServiceOffer,
serviceOffers,
} from "@/lib/site-content";
import { primaryButton, textLink } from "./button-styles"; import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink"; import ContactOfferLink from "./ContactOfferLink";
import type { ContactKind } from "./contact-subject";
import "./conversion.css"; import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink"; import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal"; import { Reveal } from "./Reveal";
import { Section } from "./Section"; import { Section } from "./Section";
interface Offer { /** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
id: string; interface OfferExtras {
title: string;
description: string; description: string;
/** O que a pessoa recebe: de três a quatro itens, só o que o case ou a página /ia citados já sustentam. */
deliverables: string[];
/** Primeira etapa: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). */
firstStep: string;
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
kind: ContactKind;
ctaLabel: string; ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, o cartão não tem foto e mostra só o ícone. */ /** Case cujo pôster ilustra a oferta; sem ele, o cartão traz uma ilustração própria (ver OfferArt). */
poster?: { id: string; title: string }; poster?: { id: string; title: string };
/** Ícone do cartão sem foto. */
icon?: LucideIcon;
moreLabel: string; moreLabel: string;
moreHref: string; moreHref: string;
} }
/** /**
* As três ofertas da home, na ordem do H1: site que traz pedidos, sistema que tira a operação da planilha e revisão * Os textos da home para as três ofertas, na ordem de `serviceOffers` (a do H1). Cada texto só diz o que o case ou a
* de sistema que já existe. Cada texto só diz o que o case ou a página de /ia citados já sustentam; sem prazo nem preço. * página de /ia citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de catálogo e
* Só o site tem foto: a Lavação 047 é um site de catálogo e tabela de preços, o que o texto descreve. O sistema e a * tabela de preços, o que o texto descreve. O sistema e a revisão não têm um case que mostre o que descrevem (a vitrine
* revisão não têm um case que mostre o que descrevem (a vitrine da SEV é uma loja, não um painel), então o cartão * da SEV é uma loja, não um painel), então cada um traz uma ilustração feita em HTML e SVG, do tamanho da foto.
* traz só um ícone, e os entregáveis dizem o que a pessoa recebe.
*/ */
const OFFERS: Offer[] = [ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
{ site: {
id: "site",
title: "Site que traz pedidos pelo WhatsApp",
description: description:
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.", "O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
deliverables: [
"Catálogo ou tabela de preços",
"Mensagem pronta no WhatsApp",
"Painel para editar preços e fotos",
"Publicação do site",
],
firstStep:
"Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
kind: "site",
ctaLabel: "Começar pelo site", ctaLabel: "Começar pelo site",
poster: { id: "lavacao-047", title: "Lavação 047" }, poster: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver case Lavação 047", moreLabel: "Ver case Lavação 047",
moreHref: "/cases/lavacao-047", moreHref: "/cases/lavacao-047",
}, },
{ sistema: {
id: "sistema",
title: "Sistema para tirar a operação da planilha",
description: description:
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe. Um exemplo é a SEV Moda Íntima, com cadastro de peças, pedidos e estoque por tamanho.", "Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe. Um exemplo é a SEV Moda Íntima, com cadastro de peças, pedidos e estoque por tamanho.",
deliverables: [
"Cadastro de clientes e produtos",
"Pedidos e acompanhamento",
"Relatórios",
"Acesso por perfil",
],
firstStep:
"Primeira conversa para entender a operação. Depois, escopo e proposta por escrito.",
kind: "sistema",
ctaLabel: "Começar pelo sistema", ctaLabel: "Começar pelo sistema",
icon: LayoutDashboard,
moreLabel: "Ver case SEV Moda Íntima", moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima", moreHref: "/cases/sev-modaintima",
}, },
{ revisao: {
id: "revisao",
title: "Revisão de sistema que já existe, inclusive feito com IA",
description: description:
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.", "Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
deliverables: [
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
],
firstStep:
"Primeira conversa para entender o app e o que ele precisa fazer. Depois, escopo e proposta por escrito.",
kind: "revisao",
ctaLabel: "Começar pela revisão", ctaLabel: "Começar pela revisão",
icon: ShieldCheck,
moreLabel: "Ver como funciona a revisão", moreLabel: "Ver como funciona a revisão",
moreHref: "/ia", moreHref: "/ia",
}, },
]; };
const PROCESS = [ const PROCESS = [
{ {
@@ -118,52 +76,230 @@ const PROCESS = [
}, },
]; ];
/** Miniatura sem foto: um painel escuro do mesmo tamanho dos pôsteres, só com o ícone da oferta. */ /** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
function OfferIcon({ icon: Icon }: Readonly<{ icon: LucideIcon }>) { const SHEET_COLUMNS = [0, 1, 2];
const SHEET_DATA_ROWS = [0, 1, 2, 3, 4];
const SHEET_DASH_WIDTHS = [18, 12, 15];
/** Alturas das cinco barras do painel do esquema. */
const PANEL_BARS = [14, 26, 20, 34, 28];
/**
* Ilustração do sistema: as linhas de uma planilha, uma seta e um painel com três indicadores e um gráfico de barras,
* sem números nem nomes de cliente. É um SVG com `viewBox` (cresce com o cartão) do mesmo tamanho da foto do site, só
* em traços claros sobre o fundo escuro, e não tem texto: o nome do cartão e a lista dizem o que é.
*/
function SystemArt() {
return (
<svg
role="img"
aria-label="Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras"
viewBox="0 0 240 150"
className="block h-full w-full text-white"
fill="none"
stroke="currentColor"
>
<g data-art="planilha" strokeOpacity="0.45" strokeWidth="1.5">
<rect x="14" y="30" width="80" height="92" />
<path d="M14 46h80M40.7 30v92M67.3 30v92" strokeWidth="1" />
</g>
<rect
x="15"
y="31"
width="78"
height="14"
fill="currentColor"
fillOpacity="0.16"
stroke="none"
/>
<g fill="currentColor" fillOpacity="0.5" stroke="none">
{SHEET_DATA_ROWS.flatMap((row) =>
SHEET_COLUMNS.map((column) => (
<rect
key={`${row}-${column}`}
x={19 + column * 26.6}
y={52 + row * 14.6}
width={
SHEET_DASH_WIDTHS[(row + column) % SHEET_DASH_WIDTHS.length]
}
height="4"
rx="1"
/>
)),
)}
</g>
<path
data-art="seta"
d="M104 76h24M119 66l11 10-11 10"
strokeOpacity="0.85"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g data-art="painel">
<rect
x="140"
y="22"
width="92"
height="108"
rx="3"
fill="currentColor"
fillOpacity="0.06"
strokeOpacity="0.45"
strokeWidth="1.5"
/>
{[147, 175, 203].map((x) => (
<g key={x}>
<rect
x={x}
y="30"
width="25"
height="32"
strokeOpacity="0.35"
strokeWidth="1"
/>
<rect
x={x + 4}
y="35"
width="12"
height="3"
fill="currentColor"
fillOpacity="0.5"
stroke="none"
/>
<rect
x={x + 4}
y="43"
width="16"
height="9"
fill="currentColor"
fillOpacity="0.92"
stroke="none"
/>
</g>
))}
<path d="M147 118h81" strokeOpacity="0.3" strokeWidth="1" />
{PANEL_BARS.map((height, index) => (
<rect
key={height}
x={151 + index * 15}
y={118 - height}
width="10"
height={height}
fill="currentColor"
fillOpacity="0.6"
stroke="none"
/>
))}
</g>
</svg>
);
}
/**
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
*/
const REPORT_EXCERPT = [
{ finding: "Dados expostos", severity: "Alta" },
{ finding: "Chave exposta", severity: "Alta" },
{ finding: "Sem backup", severity: "Média" },
] as const;
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
const SEVERITY_BADGE =
"inline-flex min-w-12 shrink-0 items-center justify-center border border-[var(--ink)] px-1.5 font-display text-[15px] leading-5";
/**
* Ilustração da revisão: um recorte do "Modelo do relatório" de /ia, com três linhas de achado, cada uma com o selo
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
* numa linha só no cartão mais estreito (três colunas em 1024 px).
*/
function ReviewArt() {
return ( return (
<div <div
data-testid="offer-icon" role="img"
className="flex h-full items-center justify-center text-white/85" aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
className="flex h-full flex-col items-center justify-center gap-sp-2 px-sp-3"
> >
<Icon aria-hidden="true" className="h-14 w-14" strokeWidth={1.25} /> <ul
aria-hidden="true"
className="w-full max-w-[300px] divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-2 py-0.5 text-[var(--ink)]"
>
{REPORT_EXCERPT.map(({ finding, severity }) => (
<li
key={finding}
className="flex items-center justify-between gap-sp-2 py-0.5 font-display text-[15px] leading-6"
>
<span className="min-w-0">{finding}</span>
<span
className={`${SEVERITY_BADGE} ${severity === "Alta" ? "bg-[var(--ink)] text-[var(--paper)]" : "text-[var(--ink)]"}`}
>
{severity}
</span>
</li>
))}
</ul>
<p
aria-hidden="true"
className="font-display text-[15px] leading-5 text-white/75"
>
Exemplo ilustrativo
</p>
</div> </div>
); );
} }
/** Um único estilo de cartão para as três ofertas: foto ou ícone, título com altura mínima, texto, o que você recebe, primeiro passo, botão e link do case. */ /** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
function OfferCard({ offer }: Readonly<{ offer: Offer }>) { function OfferArt({
const poster = offer.poster offer,
? getShowcaseAssets(offer.poster.id, "desktop") extras,
}: Readonly<{ offer: ServiceOffer; extras: OfferExtras }>) {
const poster = extras.poster
? getShowcaseAssets(extras.poster.id, "desktop")
: null; : null;
if (poster && extras.poster) {
return (
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
);
}
return offer.id === "sistema" ? <SystemArt /> : <ReviewArt />;
}
/** Um único estilo de cartão para as três ofertas: foto ou ilustração, título com altura mínima, texto, o que você recebe, botão e link do case. */
function OfferCard({
offer,
extras,
}: Readonly<{ offer: ServiceOffer; extras: OfferExtras }>) {
return ( return (
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden"> <article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
<div className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"> <div
{poster && offer.poster ? ( data-testid={`offer-art-${offer.id}`}
<Image className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
src={poster.poster} >
alt={`Site ${offer.poster.title} aberto no computador`} <OfferArt offer={offer} extras={extras} />
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
) : offer.icon ? (
<OfferIcon icon={offer.icon} />
) : null}
</div> </div>
{/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */} {/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */}
<h3 className="ds-card-title !mt-sp-5 text-balance !text-[clamp(20px,5vw,26px)] !leading-[1.1] !tracking-[-0.03em] [overflow-wrap:break-word] [word-break:normal] lg:min-h-[3.3em] lg:!text-[22px] xl:min-h-[2.2em] xl:!text-[24px]"> <h3 className="ds-card-title !mt-sp-5 text-balance !text-[clamp(20px,5vw,26px)] !leading-[1.1] !tracking-[-0.03em] [overflow-wrap:break-word] [word-break:normal] lg:min-h-[3.3em] lg:!text-[22px] xl:min-h-[2.2em] xl:!text-[24px]">
{offer.title} {offer.name}
</h3> </h3>
<p <p
className="ds-copy !mt-sp-3 !grow-0 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]" className="ds-copy !mt-sp-3 !grow-0 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
data-reel="right" data-reel="right"
> >
{offer.description} {extras.description}
</p> </p>
<div className="my-sp-5"> <div className="my-sp-5">
@@ -192,27 +328,18 @@ function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
</ul> </ul>
</div> </div>
{/* mt-auto: com cartões de alturas diferentes, o primeiro passo e o botão descem juntos até o pé do cartão */} {/* mt-auto: com cartões de alturas diferentes, o botão e o link descem juntos até o pé do cartão */}
<div className="mt-auto border-t border-[var(--border)] pt-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
</p>
<p className="mt-sp-1 font-display text-[15px] leading-6 text-[var(--ink-80)]">
{offer.firstStep}
</p>
</div>
<ContactOfferLink <ContactOfferLink
kind={offer.kind} kind={offer.id}
className={`mt-sp-5 w-full ${primaryButton}`} className={`mt-auto w-full ${primaryButton}`}
> >
{offer.ctaLabel} {extras.ctaLabel}
</ContactOfferLink> </ContactOfferLink>
<HoverPrefetchLink <HoverPrefetchLink
href={offer.moreHref} href={extras.moreHref}
className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`} className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`}
> >
{offer.moreLabel} {extras.moreLabel}
</HoverPrefetchLink> </HoverPrefetchLink>
</article> </article>
); );
@@ -228,7 +355,7 @@ export function WorkModalities() {
disableChildrenAnimation disableChildrenAnimation
> >
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6"> <div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{OFFERS.map((offer, index) => ( {serviceOffers.map((offer, index) => (
<Reveal <Reveal
key={offer.id} key={offer.id}
y={20} y={20}
@@ -236,14 +363,24 @@ export function WorkModalities() {
delay={0.08 * (index + 1)} delay={0.08 * (index + 1)}
className="min-w-0" className="min-w-0"
> >
<OfferCard offer={offer} /> <OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
</Reveal> </Reveal>
))} ))}
</div> </div>
{/* Uma vez só, depois dos três cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
</p>
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{generalFirstStep}
</p>
</div>
<HoverPrefetchLink <HoverPrefetchLink
href="/servicos" href="/servicos"
className={`${textLink} mt-sp-6 text-[var(--ink-80)]`} className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
> >
Ver todos os serviços Ver todos os serviços
</HoverPrefetchLink> </HoverPrefetchLink>
@@ -1,10 +1,12 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { SEO_IDS } from "@/lib/seo"; import { SEO_IDS } from "@/lib/seo";
import { serviceOffers, siteUrl } from "@/lib/site-content";
import CinematicHome from "./CinematicHome"; import CinematicHome from "./CinematicHome";
/** /**
* Dados estruturados da home: um único script `application/ld+json` com o negócio (ProfessionalService). O WebSite e * Dados estruturados da home: um único script `application/ld+json` com as três ofertas (nós Service ligados à
* a Person já saem do layout (app/layout.tsx), e repeti-los aqui duplicaria os dados. * Person). O WebSite e a Person já saem do layout (app/layout.tsx), e repeti-los aqui duplicaria os dados. Sem
* ProfessionalService nem LocalBusiness: a política de docs/seo-geo-operations.md os exclui.
*/ */
jest.mock("../Hero", () => ({ __esModule: true, default: () => null })); jest.mock("../Hero", () => ({ __esModule: true, default: () => null }));
@@ -39,15 +41,32 @@ describe("CinematicHome: dados estruturados", () => {
expect(container.firstElementChild).toBe(scripts[0]); expect(container.firstElementChild).toBe(scripts[0]);
}); });
it("describes only the business as a ProfessionalService, without repeating what the layout emits", () => { it("describes only the three offers as Service nodes tied to the person, without repeating what the layout emits", () => {
const [data] = structuredData(); const [data] = structuredData();
expect(data["@context"]).toBe("https://schema.org"); expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"]).toHaveLength(1); expect(data["@graph"]).toHaveLength(3);
const [business] = data["@graph"]; for (const offer of data["@graph"]) {
expect(business["@type"]).toBe("ProfessionalService"); expect(offer["@type"]).toBe("Service");
expect(business["@id"]).toBe(SEO_IDS.professionalService); expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
expect(business.founder).toEqual({ "@id": SEO_IDS.person }); expect(
offer.areaServed.map(({ name }: { name: string }) => name),
).toEqual(["Garuva", "Joinville", "Santa Catarina", "Brasil"]);
}
expect(data["@graph"].map(({ name }: { name: string }) => name)).toEqual(
serviceOffers.map(({ name }) => name),
);
expect(data["@graph"].map(({ url }: { url: string }) => url)).toEqual(
serviceOffers.map(({ href }) => `${siteUrl}${href}`),
);
expect(JSON.stringify(data)).not.toMatch(/"@type":"(WebSite|Person)"/); expect(JSON.stringify(data)).not.toMatch(/"@type":"(WebSite|Person)"/);
}); });
it("never emits ProfessionalService, LocalBusiness or an offer catalog (docs/seo-geo-operations.md)", () => {
const [data] = structuredData();
expect(JSON.stringify(data)).not.toMatch(
/ProfessionalService|LocalBusiness|OfferCatalog/,
);
});
}); });
@@ -76,24 +76,24 @@ beforeEach(() => {
}); });
describe("CinematicHome: endereço do trabalho em destaque", () => { describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project (SEV) is in the list with its own domain -> that domain goes to the hero", () => { it("path: the featured project (Lavação 047) 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.lavacao047.com.br/"),
]} ]}
/>, />,
); );
expect(lastHeroProps().workUrl).toEqual({ expect(lastHeroProps().workUrl).toEqual({
text: "sevmodaintima.com.br", text: "lavacao047.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("sev-modaintima", null)]} />); render(<CinematicHome projects={[project("lavacao-047", null)]} />);
expect(lastHeroProps().workUrl).toBeNull(); expect(lastHeroProps().workUrl).toBeNull();
}); });
@@ -101,7 +101,7 @@ 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("lavacao-047", "https://lavacao.example.com")]} projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
/>, />,
); );
@@ -112,7 +112,7 @@ describe("CinematicHome: endereço do trabalho em destaque", () => {
render( render(
<CinematicHome <CinematicHome
projects={[ projects={[
project("sev-modaintima", "https://sev-moda-intima.vercel.app/"), project("lavacao-047", "https://landing-page-lavacao047.vercel.app"),
]} ]}
/>, />,
); );
+7 -7
View File
@@ -1,7 +1,7 @@
import { safeJsonLd } from "@/lib/jsonld"; import { safeJsonLd } from "@/lib/jsonld";
import type { PortfolioProjectView } from "@/lib/portfolio-data"; import type { PortfolioProjectView } from "@/lib/portfolio-data";
import type { Testimonial } from "@/lib/proof"; import type { Testimonial } from "@/lib/proof";
import { buildProfessionalServiceJsonLd } from "@/lib/seo"; import { buildOfferServicesJsonLd } from "@/lib/seo";
import About from "../About"; import About from "../About";
import type { ClientLink } from "../ClientStrip"; import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact"; import Contact from "../Contact";
@@ -13,17 +13,17 @@ import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities"; import { WorkModalities } from "../WorkModalities";
import CinematicMain from "./CinematicMain"; import CinematicMain from "./CinematicMain";
/** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */ /** Projeto do navegador de HeroWork (a Lavação 047), cujo endereço, se for domínio próprio, aparece na barra. */
const HERO_WORK_PROJECT = "sev-modaintima"; const HERO_WORK_PROJECT = "lavacao-047";
/** /**
* Dados estruturados da home: o negócio como ProfessionalService (área de atendimento e as três ofertas). Só ele: o * Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
* WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. O script fica aqui, e não em * o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
* app/page.tsx, para a página continuar devolvendo só a home. * (docs/seo-geo-operations.md). O script fica aqui, e não em app/page.tsx, para a página continuar devolvendo só a home.
*/ */
const jsonLd = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@graph": [buildProfessionalServiceJsonLd()], "@graph": buildOfferServicesJsonLd(),
}; };
/** /**
+44 -8
View File
@@ -1,7 +1,12 @@
import { serviceOffers } from "@/lib/site-content";
import { OFFER_SUBJECTS } from "@/lib/whatsapp";
import { import {
CONTACT_FORM_HASH,
CONTACT_FORM_ID,
CONTACT_KIND_PARAM, CONTACT_KIND_PARAM,
CONTACT_KINDS, CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS, CONTACT_SUBJECT_OPTIONS,
contactKindForSubject,
contactKindHref, contactKindHref,
contactSubjectForKind, contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT, DEFAULT_CONTACT_SUBJECT,
@@ -18,16 +23,45 @@ describe("contact-subject", () => {
expect(CONTACT_SUBJECT_OPTIONS).toContain(subject); expect(CONTACT_SUBJECT_OPTIONS).toContain(subject);
} }
expect(CONTACT_KINDS).toEqual({ expect(CONTACT_KINDS).toEqual({
site: "Site para empresa", site: "Site que traz pedidos pelo WhatsApp",
sistema: "Sistemas web sob medida", sistema: "Sistema para tirar a operação da planilha",
revisao: "Revisão ou diagnóstico de sistema", revisao: "Revisão de sistema que já existe",
}); });
}); });
it("uses the name of each offer, the same one of serviceOffers (lib/site-content.ts), and the same WhatsApp phrase", () => {
expect(serviceOffers.map(({ id }) => id)).toEqual(
Object.keys(CONTACT_KINDS),
);
for (const offer of serviceOffers) {
expect(CONTACT_KINDS[offer.id]).toBe(offer.name);
expect(OFFER_SUBJECTS[offer.id]).toBe(offer.whatsappSubject);
// a frase entra em "Quero conversar sobre {assunto}.": começa em minúscula e não termina em ponto
expect(OFFER_SUBJECTS[offer.id]).toMatch(/^[a-zà-ú].*[^.]$/);
}
});
it("finds the offer of an option of the form, and none for the other options", () => {
expect(contactKindForSubject(CONTACT_KINDS.site)).toBe("site");
expect(contactKindForSubject(CONTACT_KINDS.revisao)).toBe("revisao");
for (const other of [
DEFAULT_CONTACT_SUBJECT,
"Aplicativos",
"",
"constructor",
null,
undefined,
]) {
expect(contactKindForSubject(other)).toBeUndefined();
}
});
it("looks a type up by its own keys only", () => { it("looks a type up by its own keys only", () => {
expect(contactSubjectForKind("site")).toBe("Site para empresa"); expect(contactSubjectForKind("site")).toBe(
"Site que traz pedidos pelo WhatsApp",
);
expect(contactSubjectForKind("revisao")).toBe( expect(contactSubjectForKind("revisao")).toBe(
"Revisão ou diagnóstico de sistema", "Revisão de sistema que já existe",
); );
for (const unknown of [ for (const unknown of [
"", "",
@@ -42,15 +76,17 @@ describe("contact-subject", () => {
expect(contactSubjectForKind(undefined)).toBeUndefined(); expect(contactSubjectForKind(undefined)).toBeUndefined();
}); });
it("builds the address with the type in the search, before the fragment, so #contact still finds the section", () => { it("builds the address with the type in the search, before the fragment, so #contact-form still finds the form card", () => {
expect(CONTACT_FORM_ID).toBe("contact-form");
expect(contactKindHref("site")).toBe( expect(contactKindHref("site")).toBe(
`/?${CONTACT_KIND_PARAM}=site#contact`, `/?${CONTACT_KIND_PARAM}=site${CONTACT_FORM_HASH}`,
); );
expect(contactKindHref("site")).toBe("/?tipo=site#contact-form");
for (const kind of Object.keys(CONTACT_KINDS) as Array< for (const kind of Object.keys(CONTACT_KINDS) as Array<
keyof typeof CONTACT_KINDS keyof typeof CONTACT_KINDS
>) { >) {
const url = new URL(contactKindHref(kind), "https://example.com"); const url = new URL(contactKindHref(kind), "https://example.com");
expect(url.hash).toBe("#contact"); expect(url.hash).toBe("#contact-form");
expect(url.searchParams.get("tipo")).toBe(kind); expect(url.searchParams.get("tipo")).toBe(kind);
expect(url.pathname).toBe("/"); expect(url.pathname).toBe("/");
} }
+31 -12
View File
@@ -1,35 +1,43 @@
/** /**
* Tipos de projeto do formulário de contato (Contact.tsx) e o jeito de as ofertas da home escolherem um deles. * Tipos de projeto do formulário de contato (Contact.tsx) e o jeito de as ofertas da home escolherem um deles.
* Uma oferta abre o formulário com o tipo já marcado de dois jeitos: o endereço `/?tipo=<tipo>#contact` (vale ao * Uma oferta abre o formulário com o tipo já marcado de dois jeitos: o endereço `/?tipo=<tipo>#contact-form` (vale ao
* chegar de outra página ou em um link copiado) e o evento CONTACT_KIND_EVENT (vale na própria home, onde o * chegar de outra página ou em um link copiado) e o evento CONTACT_KIND_EVENT (vale na própria home, onde o
* gerenciador de âncoras rola até o formulário sem recarregar a página, e ContactOfferLink deixa o endereço como `/#contact`). * gerenciador de âncoras rola até o formulário sem recarregar a página, e ContactOfferLink deixa o endereço como `/#contact-form`).
* O tipo vai na busca, antes do `#`: um `/#contact?tipo=...` faria o fragmento virar "contact?tipo=...", * O destino é o cartão do formulário (`#contact-form`), e não a cena inteira (`#contact`): no celular o formulário fica
* abaixo do texto e dos contatos diretos, e rolar até o começo da cena parava acima do campo do tipo.
* O tipo vai na busca, antes do `#`: um `/#contact-form?tipo=...` faria o fragmento virar "contact-form?tipo=...",
* que não é o id de nenhum elemento, e a página não rolaria até o formulário. * que não é o id de nenhum elemento, e a página não rolaria até o formulário.
*/ */
import { offerLabels } from "@/lib/offer-labels";
/** Primeiro e padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */ /** Primeiro e padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir"; export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
/**
* As três ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
* módulo pequeno que o navegador pode importar, ao contrário de lib/site-content.ts, que reúne o texto de todas as páginas.
*/
export const CONTACT_SUBJECT_OPTIONS = [ export const CONTACT_SUBJECT_OPTIONS = [
DEFAULT_CONTACT_SUBJECT, DEFAULT_CONTACT_SUBJECT,
"Site para empresa", offerLabels.site.name,
"Sistemas web sob medida", offerLabels.sistema.name,
"Aplicativos", "Aplicativos",
"Integrações e automações", "Integrações e automações",
"Evolução de um sistema existente", "Evolução de um sistema existente",
"Revisão ou diagnóstico de sistema", offerLabels.revisao.name,
] as const; ] as const;
/** Tipo que uma oferta pede -> opção do formulário. */ /** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
export const CONTACT_KINDS = { export const CONTACT_KINDS = {
site: "Site para empresa", site: offerLabels.site.name,
sistema: "Sistemas web sob medida", sistema: offerLabels.sistema.name,
revisao: "Revisão ou diagnóstico de sistema", revisao: offerLabels.revisao.name,
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>; } as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
export type ContactKind = keyof typeof CONTACT_KINDS; export type ContactKind = keyof typeof CONTACT_KINDS;
/** Parâmetro de busca do endereço: `/?tipo=site#contact`. */ /** Parâmetro de busca do endereço: `/?tipo=site#contact-form`. */
export const CONTACT_KIND_PARAM = "tipo"; export const CONTACT_KIND_PARAM = "tipo";
/** Evento da janela com o tipo escolhido em `detail`. */ /** Evento da janela com o tipo escolhido em `detail`. */
export const CONTACT_KIND_EVENT = "contact:kind"; export const CONTACT_KIND_EVENT = "contact:kind";
@@ -41,6 +49,17 @@ export function contactSubjectForKind(kind: string | null | undefined) {
: undefined; : undefined;
} }
/** Id do cartão do formulário de contato: o destino das ofertas (o campo do tipo fica a poucos toques do topo dele). */
export const CONTACT_FORM_ID = "contact-form";
export const CONTACT_FORM_HASH = `#${CONTACT_FORM_ID}`;
export function contactKindHref(kind: ContactKind) { export function contactKindHref(kind: ContactKind) {
return `/?${CONTACT_KIND_PARAM}=${kind}#contact`; return `/?${CONTACT_KIND_PARAM}=${kind}${CONTACT_FORM_HASH}`;
}
/** Oferta cujo nome é a opção do formulário; `undefined` para as outras opções (e para o padrão). */
export function contactKindForSubject(subject: string | null | undefined) {
return (Object.keys(CONTACT_KINDS) as ContactKind[]).find(
(kind) => CONTACT_KINDS[kind] === subject,
);
} }