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