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";
// 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
* 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");
});
it("path: the story, the profile points and the résumé link are all there", () => {
render(<About />);
it("path: the section has three points, each with the proof that backs it, and the résumé link", () => {
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(
screen.getByRole("heading", {
level: 3,
name: "Sem intermediários entre o pedido e o sistema",
within(story[1]).getByRole("link", {
name: "Ver como funciona a revisão",
}),
).toBeInTheDocument();
expect(
screen.getByText("Solução do tamanho do seu problema"),
).toBeInTheDocument();
).toHaveAttribute("href", "/ia");
expect(
screen.getByRole("link", { name: "Ver trajetória completa" }),
).toHaveAttribute("href", "/curriculo");
});
it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work", () => {
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 />);
expect(container.textContent).not.toMatch(
@@ -116,14 +178,14 @@ describe("About", () => {
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
"Evandro Fonseca Junior",
);
expect(screen.getByTestId("about-description").textContent).toMatch(
/^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\./,
expect(screen.getByTestId("about-description").textContent).toBe(
"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
expect(container.textContent).not.toMatch(
/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);
});
@@ -131,7 +193,7 @@ describe("About", () => {
const { container } = render(<About />);
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", () => {
@@ -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 />);
expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass(
"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");
for (const card of Array.from(story).slice(0, 3)) {
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 { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage";
import { textLink } from "./button-styles";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal";
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 = [
{
label: "Acompanhamento",
title: "Sem intermediários entre o pedido e o sistema",
label: "Quem desenvolve",
title: "Quem conversa com você escreve o código",
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",
title: "Aproveito o que você já tem",
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.",
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",
title: "Do código ao sistema no ar",
content:
"Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, junto com as funcionalidades.",
},
];
const PROFILE_POINTS = [
{
title: "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.",
proof:
"Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços.",
},
];
@@ -55,7 +49,7 @@ export default function About() {
id="about"
eyebrow="Quem conduz o projeto"
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
aside={
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
@@ -64,7 +58,8 @@ export default function About() {
</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">
{STORY_DATA.map((step, index) => (
<Reveal
@@ -81,6 +76,18 @@ export default function About() {
<p className="ds-copy" data-reel="right">
{step.content}
</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>
</Reveal>
))}
@@ -90,24 +97,25 @@ export default function About() {
y={20}
duration={0.8}
delay={cardDelay(STORY_DATA.length)}
className="h-full"
className="lg:sticky lg:top-28"
>
<article className="ds-card cv-press cv-lift h-full">
<div className="flex items-center gap-sp-5">
<article className="ds-card cv-press cv-lift">
<figure>
<LazyRevealImage
src={profilePhoto}
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
className="aspect-square w-28 shrink-0 overflow-hidden rounded-[6px] border border-[var(--border)] sm:w-32"
imageClassName="object-cover"
sizes="128px"
alt="Retrato de Evandro Fonseca Junior"
className="aspect-[4/3] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-cover object-[50%_25%]"
sizes="(min-width: 1024px) 50vw, 100vw"
/>
<div className="min-w-0">
<div className="ds-kicker">Da ideia ao sistema no ar</div>
<h3 className="ds-card-title !mt-sp-2">
Uma pessoa cuida do sistema inteiro
</h3>
</div>
</div>
<figcaption className="mt-sp-3 font-display text-[15px] leading-6 text-[var(--ink-80)]">
Evandro Fonseca Junior · Engenheiro da computação · Garuva, SC
</figcaption>
</figure>
<div className="ds-kicker mt-sp-6">Do que eu cuido</div>
<h3 className="ds-card-title !mt-sp-2">
Tela, dados e equipamentos
</h3>
<DrawLine delay={0.05} />
<p className="ds-copy" data-reel="top">
Cuido da tela que sua equipe usa, dos dados por trás dela e,
@@ -115,25 +123,12 @@ export default function About() {
sistema. Ver tudo junto ajuda a decidir o que construir, o que
conectar e o que dá para aproveitar.
</p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<div className="text-[15px] font-medium tracking-[0.02em]">
{point.title}
</div>
<p className="ds-copy !mt-sp-2">{point.description}</p>
</div>
))}
</div>
<Link
<HoverPrefetchLink
href="/curriculo"
className={`${textLink} mt-sp-4 text-[var(--ink-80)]`}
>
Ver trajetória completa
</Link>
</HoverPrefetchLink>
</article>
</Reveal>
</div>
+18 -8
View File
@@ -3,7 +3,7 @@ import Contact from "./Contact";
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.
*/
@@ -38,14 +38,22 @@ describe("Contact: tipo no endereço", () => {
});
it.each([
["/?tipo=site#contact", "Site para empresa"],
["/?tipo=sistema#contact", "Sistemas web sob medida"],
["/?tipo=site#contact-form", "Site que traz pedidos pelo WhatsApp"],
[
"/?utm_source=x&tipo=revisao#contact",
"Revisão ou diagnóstico de sistema",
"/?tipo=sistema#contact-form",
"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
["/?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) => {
window.history.replaceState(null, "", address);
@@ -60,7 +68,9 @@ describe("Contact: tipo no endereço", () => {
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", () => {
@@ -69,6 +79,6 @@ describe("Contact: tipo no endereço", () => {
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
* (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 = () =>
@@ -23,18 +24,18 @@ afterEach(() => {
});
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 />);
const options = within(subjectSelect()).getAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Ainda preciso definir",
"Site para empresa",
"Sistemas web sob medida",
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Aplicativos",
"Integrações e automações",
"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");
});
@@ -45,24 +46,22 @@ describe("Contact: opções do tipo de projeto", () => {
await user.selectOptions(
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 body = new URL(mail.getAttribute("href") ?? "").searchParams.get(
"body",
);
expect(body).toContain(
"Tipo de projeto: Revisão ou diagnóstico de sistema",
);
expect(body).toContain("Tipo de projeto: Revisão de sistema que já existe");
});
});
describe("Contact: tipo escolhido pela oferta", () => {
it.each([
["site", "Site para empresa"],
["sistema", "Sistemas web sob medida"],
["revisao", "Revisão ou diagnóstico de sistema"],
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["revisao", "Revisão de sistema que já existe"],
])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />);
@@ -80,7 +79,7 @@ describe("Contact: tipo escolhido pela oferta", () => {
expect(subjectSelect()).toHaveValue("Aplicativos");
choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe");
});
it.each([
@@ -97,15 +96,20 @@ describe("Contact: tipo escolhido pela oferta", () => {
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)", () => {
window.history.replaceState(null, "", "/?tipo=revisao#contact");
window.history.replaceState(null, "", "/?tipo=revisao#contact-form");
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", () => {
@@ -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", () => {
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
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,
);
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");
});
+34 -2
View File
@@ -5,11 +5,19 @@ import type { FormEvent, MouseEvent, ReactNode } from "react";
import { useEffect, useState } from "react";
import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics";
import { CONTACT_EMAIL } from "@/lib/site-navigation";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import {
buildWhatsAppUrl,
getWhatsAppDisplayNumber,
OFFER_SUBJECTS,
} from "@/lib/whatsapp";
import {
CONTACT_FORM_ID,
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS,
type ContactKind,
contactKindForSubject,
contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT,
} from "./contact-subject";
@@ -162,13 +170,17 @@ export default function Contact() {
message: "",
});
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
// página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada.
useEffect(() => {
const choose = (kind: string | null | undefined) => {
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));
const onChoose = (event: Event) =>
@@ -179,6 +191,7 @@ export default function Contact() {
const updateField = (field: ContactField | "subject", value: string) => {
setFormData((current) => ({ ...current, [field]: value }));
if (field === "subject") setChosenOffer(null);
if (field !== "subject" && errors[field]) {
setErrors((current) => ({ ...current, [field]: undefined }));
}
@@ -195,6 +208,12 @@ export default function Contact() {
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>) => {
e.preventDefault();
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">
<WhatsAppLink
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"
>
<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>
<form
id={CONTACT_FORM_ID}
onSubmit={handleSubmit}
noValidate
aria-labelledby="contact-form-title"
@@ -358,6 +379,17 @@ export default function Contact() {
</select>
)}
</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
id="contact-message"
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).
*
* 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
* 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;
* - 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.
*/
@@ -52,7 +52,7 @@ beforeEach(() => {
window.history.replaceState(null, "", "/");
replaceState = jest.spyOn(window.history, "replaceState");
form = document.createElement("section");
form.id = "contact";
form.id = "contact-form";
document.body.append(form);
});
@@ -96,10 +96,10 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
expect(scrollIntoView).not.toHaveBeenCalled();
expect(form).toHaveFocus();
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.location.search).toBe("");
expect(window.location.hash).toBe("#contact");
expect(window.location.hash).toBe("#contact-form");
expect(replaceState).toHaveBeenCalledTimes(1);
} finally {
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", () => {
window.history.replaceState(null, "", "/#contact");
it("path: the manager takes the click and the address is already /#contact-form -> no replaceState at all", () => {
window.history.replaceState(null, "", "/#contact-form");
replaceState.mockClear();
const stop = installAnchorManager();
try {
@@ -118,7 +118,9 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(replaceState).not.toHaveBeenCalled();
expect(window.location.pathname + window.location.hash).toBe("/#contact");
expect(window.location.pathname + window.location.hash).toBe(
"/#contact-form",
);
} finally {
stop();
}
@@ -131,9 +133,9 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => {
expect(fireEvent.click(link)).toBe(false);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
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();
replaceState.mockClear();
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)", () => {
const routerState = { __NA: true, tree: ["", {}] };
window.history.replaceState(routerState, "", "/?tipo=site#contact");
window.history.replaceState(routerState, "", "/?tipo=site#contact-form");
replaceState.mockClear();
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
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);
});
});
+15 -11
View File
@@ -27,7 +27,7 @@ describe("ContactOfferLink", () => {
);
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).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
@@ -88,7 +88,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
window.history.replaceState(null, "", "/");
setReducedMotion(false);
form = document.createElement("section");
form.id = "contact";
form.id = "contact-form";
document.body.append(form);
});
@@ -98,7 +98,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
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 listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
@@ -120,7 +120,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
});
expect(form).toHaveFocus();
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();
} finally {
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", () => {
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>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
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", () => {
window.history.replaceState(null, "", "/?tipo=site#contact");
window.history.replaceState(null, "", "/?tipo=site#contact-form");
const cancel = (event: Event) => event.preventDefault();
document.addEventListener("click", cancel, true);
try {
@@ -158,7 +162,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact");
expect(window.location.hash).toBe("#contact-form");
} finally {
document.removeEventListener("click", cancel, true);
}
@@ -176,19 +180,19 @@ describe("ContactOfferLink: rolagem até o formulário", () => {
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");
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
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(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.pathname).toBe("/privacidade");
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();
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
+10 -11
View File
@@ -8,6 +8,7 @@ import {
isPlainPrimaryClick,
} from "./cinematic/fragment-nav";
import {
CONTACT_FORM_HASH,
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
type ContactKind,
@@ -15,17 +16,15 @@ import {
} from "./contact-subject";
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=`
* que uma oferta anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não
* uma navegação do Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
* Deixa o endereço da home com o fragmento do cartão do formulário (`/#contact-form`) e sem o `?tipo=` que uma oferta
* anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não uma navegação do
* Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
*/
function showContactInAddress() {
const url = new URL(window.location.href);
url.searchParams.delete(CONTACT_KIND_PARAM);
url.hash = CONTACT_HASH;
url.hash = CONTACT_FORM_HASH;
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
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.
* Na própria home o tipo segue por evento e a página rola até `#contact` (sem navegar pelo Next); o `href` com
* `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
* 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
* à 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
* 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
* 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á
* 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({
kind,
@@ -56,7 +55,7 @@ export default function ContactOfferLink({
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
);
if (window.location.pathname !== "/") return;
const form = elementForHash(CONTACT_HASH);
const form = elementForHash(CONTACT_FORM_HASH);
if (!form) return;
if (event.defaultPrevented) {
showContactInAddress();
+17 -1
View File
@@ -1,6 +1,8 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { resetNavigation, setNavigation } from "@/__mocks__/next/navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
import GoogleAnalytics from "./GoogleAnalytics";
@@ -50,6 +52,7 @@ const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
beforeEach(() => {
resetNavigation();
instances.length = 0;
global.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");
});
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 />);
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.
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
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" },
};
@@ -57,7 +56,7 @@ describe("message by route", () => {
it.each([
[
"/servicos/criacao-de-sites",
"Quero conversar sobre Sites profissionais.",
"Quero conversar sobre um site que traz pedidos pelo WhatsApp.",
],
[
"/cases/sev-modaintima",
@@ -66,7 +65,7 @@ describe("message by route", () => {
["/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
["/", "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",
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
@@ -132,7 +131,7 @@ describe("page changes", () => {
rerender(<FloatingWhatsApp contexts={contexts} />);
expect(instances[0].disconnect).toHaveBeenCalled();
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 { HOME_SUBJECT } from "@/lib/whatsapp";
import FloatingWhatsApp from "./FloatingWhatsApp";
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");
const url = new URL(link?.getAttribute("href") ?? "");
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(
"Quero conversar sobre um projeto.",
`Quero conversar sobre ${HOME_SUBJECT}.`,
);
// alvo de 56 px, canto inferior direito, só abaixo de 768 px
expect(link).toHaveClass("h-14", "w-14");
+27 -1
View File
@@ -73,7 +73,9 @@ describe("Footer contact and navigation", () => {
"/cases",
);
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");
expect(
within(more).getByRole("link", { name: "Privacidade" }),
@@ -143,6 +145,30 @@ describe("Footer social links", () => {
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)", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
+6 -3
View File
@@ -5,6 +5,7 @@ import {
trackEmailClick,
trackSocialClick,
} from "@/lib/analytics";
import { offerLabels } from "@/lib/offer-labels";
import {
CONTACT_EMAIL,
INSTAGRAM_URL,
@@ -31,8 +32,10 @@ export default function Footer({
const currentYear = new Date().getFullYear();
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
<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)]">
// 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.
// 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="grid gap-sp-8 md:grid-cols-3 md:gap-sp-10">
<section aria-labelledby="footer-contact-title">
@@ -115,7 +118,7 @@ export default function Footer({
</li>
<li>
<HoverPrefetchLink href="/ia" className={linkClassName}>
Revisão de app feito com IA
{offerLabels.revisao.name}
</HoverPrefetchLink>
</li>
<li>
+28 -5
View File
@@ -75,15 +75,38 @@ afterEach(() => {
});
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");
const user = userEvent.setup();
const { container } = render(<GoogleAnalytics />);
await act(async () => {});
render(<GoogleAnalytics />);
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(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 () => {
+5 -2
View File
@@ -212,7 +212,7 @@ function GoogleAnalyticsConsent() {
return (
<>
{consent === "granted" && (
{consent === "granted" && process.env.NODE_ENV === "production" && (
<Script
id="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() {
if (process.env.NODE_ENV !== "production") return null;
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.
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
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).
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
+5 -4
View File
@@ -5,7 +5,6 @@ import type { WhatsAppContexts } from "@/lib/whatsapp";
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
import { Header } from "./Header";
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
@@ -23,7 +22,8 @@ jest.mock("next/link", () => ({
}));
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" },
};
@@ -233,7 +233,7 @@ describe("Header", () => {
"/cases",
"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",
"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).toHaveAttribute("target", "_blank");
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", () => {
+2 -2
View File
@@ -152,9 +152,9 @@ describe("Hero", () => {
image.getAttribute("src") ?? "",
"http://localhost",
).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(
"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");
});
+22 -4
View File
@@ -17,7 +17,7 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
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");
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
expect(desktop[0]).toHaveAttribute(
@@ -29,12 +29,30 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
"(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
.getByTestId("hero-work-mobile")
.querySelector("img") as HTMLImageElement;
expect(original(small)).toBe("/videos/sev-modaintima/desktop.webp");
expect(small).toHaveAttribute("sizes", "(min-width: 1024px) 1px, 100vw");
expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
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", () => {
+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). */
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";
/** 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_TITLE = "SEV Moda Íntima";
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
* 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;
/**
* 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
* Moda Íntima no navegador (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo
* da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
* 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
* 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
* 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
* 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
@@ -34,7 +41,7 @@ const SMALL_CARD_IMAGE_HEIGHT = 72;
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "desktop");
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
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 */}
<Image
src={desktop.poster}
alt="Site da SEV Moda Íntima aberto no computador"
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
width={desktop.width}
height={desktop.height}
sizes="(min-width: 1400px) 500px, 36vw"
@@ -87,10 +94,10 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, 100vw"
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
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 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.
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
@@ -2,8 +2,6 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink";
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
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";
import { type RefObject, useEffect, useRef, useState } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { secondaryButtonDark } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink";
@@ -7,12 +8,69 @@ import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel";
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). */
export default function Projects({
projects,
}: {
projects: PortfolioProjectView[];
}) {
const exitRef = useRef<HTMLDivElement>(null);
const offset = useReelColumnOffset(exitRef);
return (
<Section
id="projects"
@@ -26,7 +84,12 @@ export default function Projects({
<ProjectReel />
</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}>
Ver os cases
</HoverPrefetchLink>
+104 -50
View File
@@ -1,11 +1,13 @@
import { render, screen, within } from "@testing-library/react";
import { serviceOffers } from "@/lib/site-content";
import { primaryButton } from "./button-styles";
import { WorkModalities } from "./WorkModalities";
/**
* 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
* 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.
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (nome e "O que você recebe" vindos de
* `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).
*/
@@ -86,7 +88,7 @@ describe("WorkModalities: movimento", () => {
});
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 />);
expect(
@@ -97,19 +99,20 @@ describe("WorkModalities: as três ofertas", () => {
).toBeInTheDocument();
const articles = Array.from(container.querySelectorAll("article"));
expect(articles).toHaveLength(3);
expect(
articles.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
),
).toEqual([
const titles = articles.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
);
expect(titles).toEqual([
"Site que traz pedidos pelo WhatsApp",
"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 [site] = Array.from(container.querySelectorAll("article"));
@@ -118,15 +121,10 @@ describe("WorkModalities: as três ofertas", () => {
within(site)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual([
"Catálogo ou tabela de preços",
"Mensagem pronta no WhatsApp",
"Painel para editar preços e fotos",
"Publicação do site",
]);
).toEqual(serviceOffers[0].deliverables);
});
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 [site, system, review] = Array.from(
@@ -143,43 +141,91 @@ describe("WorkModalities: as três ofertas", () => {
);
expect(siteImage).toHaveAttribute("loading", "lazy");
// 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(review.querySelector("img")).toBeNull();
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 [, system, review] = Array.from(
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(
within(system)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual([
"Cadastro de clientes e produtos",
"Pedidos e acompanhamento",
"Relatórios",
"Acesso por perfil",
]);
).toEqual(serviceOffers[1].deliverables);
expect(
within(review)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual([
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]);
).toEqual(serviceOffers[2].deliverables);
});
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 cards = Array.from(container.querySelectorAll("article"));
for (const card of cards) {
expect(within(card).getAllByText("Primeiro passo")).toHaveLength(1);
expect(card.textContent).toMatch(
/Primeira conversa para entender .+\. Depois, escopo e proposta por escrito\./,
);
for (const card of container.querySelectorAll("article")) {
expect(within(card).queryByText("Primeiro passo")).toBeNull();
}
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 articles = Array.from(container.querySelectorAll("article"));
const buttons = [
["Começar pelo site", "/?tipo=site#contact"],
["Começar pelo sistema", "/?tipo=sistema#contact"],
["Começar pela revisão", "/?tipo=revisao#contact"],
["Começar pelo site", "/?tipo=site#contact-form"],
["Começar pelo sistema", "/?tipo=sistema#contact-form"],
["Começar pela revisão", "/?tipo=revisao#contact-form"],
] as const;
buttons.forEach(([name, href], index) => {
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 { getShowcaseAssets } from "@/lib/project-showcase";
import {
generalFirstStep,
type ServiceOffer,
serviceOffers,
} from "@/lib/site-content";
import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink";
import type { ContactKind } from "./contact-subject";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
interface Offer {
id: string;
title: string;
/** 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. */
interface OfferExtras {
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;
/** 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 };
/** Ícone do cartão sem foto. */
icon?: LucideIcon;
moreLabel: 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
* 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.
* 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
* 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
* traz só um ícone, e os entregáveis dizem o que a pessoa recebe.
* 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
* 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
* 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
* 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.
*/
const OFFERS: Offer[] = [
{
id: "site",
title: "Site que traz pedidos pelo WhatsApp",
const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
site: {
description:
"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",
poster: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver case Lavação 047",
moreHref: "/cases/lavacao-047",
},
{
id: "sistema",
title: "Sistema para tirar a operação da planilha",
sistema: {
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.",
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",
icon: LayoutDashboard,
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
{
id: "revisao",
title: "Revisão de sistema que já existe, inclusive feito com IA",
revisao: {
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.",
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",
icon: ShieldCheck,
moreLabel: "Ver como funciona a revisão",
moreHref: "/ia",
},
];
};
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. */
function OfferIcon({ icon: Icon }: Readonly<{ icon: LucideIcon }>) {
/** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
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 (
<div
data-testid="offer-icon"
className="flex h-full items-center justify-center text-white/85"
role="img"
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>
);
}
/** 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. */
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
const poster = offer.poster
? getShowcaseAssets(offer.poster.id, "desktop")
/** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
function OfferArt({
offer,
extras,
}: Readonly<{ offer: ServiceOffer; extras: OfferExtras }>) {
const poster = extras.poster
? getShowcaseAssets(extras.poster.id, "desktop")
: 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 (
<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]">
{poster && offer.poster ? (
<Image
src={poster.poster}
alt={`Site ${offer.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"
/>
) : offer.icon ? (
<OfferIcon icon={offer.icon} />
) : null}
<div
data-testid={`offer-art-${offer.id}`}
className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
>
<OfferArt offer={offer} extras={extras} />
</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 */}
<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>
<p
className="ds-copy !mt-sp-3 !grow-0 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
data-reel="right"
>
{offer.description}
{extras.description}
</p>
<div className="my-sp-5">
@@ -192,27 +328,18 @@ function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
</ul>
</div>
{/* mt-auto: com cartões de alturas diferentes, o primeiro passo e o botão 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>
{/* mt-auto: com cartões de alturas diferentes, o botão e o link descem juntos até o pé do cartão */}
<ContactOfferLink
kind={offer.kind}
className={`mt-sp-5 w-full ${primaryButton}`}
kind={offer.id}
className={`mt-auto w-full ${primaryButton}`}
>
{offer.ctaLabel}
{extras.ctaLabel}
</ContactOfferLink>
<HoverPrefetchLink
href={offer.moreHref}
href={extras.moreHref}
className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`}
>
{offer.moreLabel}
{extras.moreLabel}
</HoverPrefetchLink>
</article>
);
@@ -228,7 +355,7 @@ export function WorkModalities() {
disableChildrenAnimation
>
<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
key={offer.id}
y={20}
@@ -236,14 +363,24 @@ export function WorkModalities() {
delay={0.08 * (index + 1)}
className="min-w-0"
>
<OfferCard offer={offer} />
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
</Reveal>
))}
</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
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
</HoverPrefetchLink>
@@ -1,10 +1,12 @@
import { render } from "@testing-library/react";
import { SEO_IDS } from "@/lib/seo";
import { serviceOffers, siteUrl } from "@/lib/site-content";
import CinematicHome from "./CinematicHome";
/**
* Dados estruturados da home: um único script `application/ld+json` com o negócio (ProfessionalService). O WebSite e
* a Person já saem do layout (app/layout.tsx), e repeti-los aqui duplicaria os dados.
* Dados estruturados da home: um único script `application/ld+json` com as três ofertas (nós Service ligados à
* 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 }));
@@ -39,15 +41,32 @@ describe("CinematicHome: dados estruturados", () => {
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();
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"]).toHaveLength(1);
const [business] = data["@graph"];
expect(business["@type"]).toBe("ProfessionalService");
expect(business["@id"]).toBe(SEO_IDS.professionalService);
expect(business.founder).toEqual({ "@id": SEO_IDS.person });
expect(data["@graph"]).toHaveLength(3);
for (const offer of data["@graph"]) {
expect(offer["@type"]).toBe("Service");
expect(offer.provider).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)"/);
});
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", () => {
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(
<CinematicHome
projects={[
project("lavacao-047", "https://www.landing-lavacao.example.com/"),
project("sev-modaintima", "https://sevmodaintima.com.br"),
project("lavacao-047", "https://www.lavacao047.com.br/"),
]}
/>,
);
expect(lastHeroProps().workUrl).toEqual({
text: "sevmodaintima.com.br",
text: "lavacao047.com.br",
secure: true,
});
});
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();
});
@@ -101,7 +101,7 @@ describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project is not in the list -> no address", () => {
render(
<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(
<CinematicHome
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 type { PortfolioProjectView } from "@/lib/portfolio-data";
import type { Testimonial } from "@/lib/proof";
import { buildProfessionalServiceJsonLd } from "@/lib/seo";
import { buildOfferServicesJsonLd } from "@/lib/seo";
import About from "../About";
import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact";
@@ -13,17 +13,17 @@ import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities";
import CinematicMain from "./CinematicMain";
/** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */
const HERO_WORK_PROJECT = "sev-modaintima";
/** 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 = "lavacao-047";
/**
* Dados estruturados da home: o negócio como ProfessionalService (área de atendimento e as três ofertas). Só ele: o
* WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. O script fica aqui, e não em
* app/page.tsx, para a página continuar devolvendo só a home.
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
* (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 = {
"@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 {
CONTACT_FORM_HASH,
CONTACT_FORM_ID,
CONTACT_KIND_PARAM,
CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS,
contactKindForSubject,
contactKindHref,
contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT,
@@ -18,16 +23,45 @@ describe("contact-subject", () => {
expect(CONTACT_SUBJECT_OPTIONS).toContain(subject);
}
expect(CONTACT_KINDS).toEqual({
site: "Site para empresa",
sistema: "Sistemas web sob medida",
revisao: "Revisão ou diagnóstico de sistema",
site: "Site que traz pedidos pelo WhatsApp",
sistema: "Sistema para tirar a operação da planilha",
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", () => {
expect(contactSubjectForKind("site")).toBe("Site para empresa");
expect(contactSubjectForKind("site")).toBe(
"Site que traz pedidos pelo WhatsApp",
);
expect(contactSubjectForKind("revisao")).toBe(
"Revisão ou diagnóstico de sistema",
"Revisão de sistema que já existe",
);
for (const unknown of [
"",
@@ -42,15 +76,17 @@ describe("contact-subject", () => {
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(
`/?${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<
keyof typeof CONTACT_KINDS
>) {
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.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.
* 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
* gerenciador de âncoras rola até o formulário sem recarregar a página, e ContactOfferLink deixa o endereço como `/#contact`).
* O tipo vai na busca, antes do `#`: um `/#contact?tipo=...` faria o fragmento virar "contact?tipo=...",
* gerenciador de âncoras rola até o formulário sem recarregar a página, e ContactOfferLink deixa o endereço como `/#contact-form`).
* 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.
*/
import { offerLabels } from "@/lib/offer-labels";
/** 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";
/**
* 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 = [
DEFAULT_CONTACT_SUBJECT,
"Site para empresa",
"Sistemas web sob medida",
offerLabels.site.name,
offerLabels.sistema.name,
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão ou diagnóstico de sistema",
offerLabels.revisao.name,
] 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 = {
site: "Site para empresa",
sistema: "Sistemas web sob medida",
revisao: "Revisão ou diagnóstico de sistema",
site: offerLabels.site.name,
sistema: offerLabels.sistema.name,
revisao: offerLabels.revisao.name,
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
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";
/** Evento da janela com o tipo escolhido em `detail`. */
export const CONTACT_KIND_EVENT = "contact:kind";
@@ -41,6 +49,17 @@ export function contactSubjectForKind(kind: string | null | 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) {
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,
);
}