feat(servicos): ofertas no topo de /servicos, capa por assunto e diagramas
/servicos abre com as 3 ofertas e depois outros serviços; H1 de criação de sites e de engenharia com o nome da oferta; capa de cada serviço pelo assunto (SEV fora do próprio case cai de ~11 para 2 imagens); diagrama de fluxo em SVG nos serviços sem case; cases com o que o cliente passou a poder fazer e contato logo depois de Como ficou; FAQ num componente só; lastmod por rota; ProfessionalService com areaServed.
This commit is contained in:
1 parent
e63f3c2ebd
commit
fc44a69fb3
41 files changed
+2486
-550
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { buttonLabels } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseCapabilitiesSection: com itens e "Como ficou" (lista e botão), só com "Como ficou"
|
||||
* (só o botão), só com itens (só a lista, sem repetir o botão que o fecho da página já traz) e sem nada (não aparece).
|
||||
* A existência das capturas em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => "/cases" }));
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
const capabilities = [
|
||||
"O dono muda os preços sem pedir a ninguém.",
|
||||
"O cliente pede pelo WhatsApp com a mensagem pronta.",
|
||||
];
|
||||
const withSite = {
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: "https://exemplo.com.br",
|
||||
};
|
||||
|
||||
function messageOf(link: HTMLElement) {
|
||||
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("CaseCapabilitiesSection", () => {
|
||||
it("path: items and a site on the air -> the list under the client title, then the contact button", () => {
|
||||
render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{ ...withSite, capabilities }}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que o cliente passou a poder fazer",
|
||||
}),
|
||||
).toBeVisible();
|
||||
for (const item of capabilities) {
|
||||
expect(screen.getByText(item)).toBeVisible();
|
||||
}
|
||||
const button = screen.getByRole("link", { name: buttonLabels.contact });
|
||||
expect(button).toHaveAttribute("target", "_blank");
|
||||
expect(button.className).toContain("bg-[var(--slash)]");
|
||||
expect(messageOf(button)).toContain(
|
||||
`Quero conversar sobre ${similarProjectSubject(withSite.title)}.`,
|
||||
);
|
||||
// o botão vem depois da lista
|
||||
expect(
|
||||
screen.getByText(capabilities[1]).compareDocumentPosition(button) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("path: a laboratory case with items -> the title does not talk about a client", () => {
|
||||
render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{ ...withSite, capabilities }}
|
||||
isClient={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que dá para fazer com ele",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText(/o cliente passou a poder fazer/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no items but a capture on disk (no site) -> only the contact button, under a heading that only screen readers get", () => {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
id === "projeto-teste" ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
const { container } = render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: null,
|
||||
}}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("ul")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: buttonLabels.contact }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("heading", { level: 2 })).toHaveClass("sr-only");
|
||||
});
|
||||
|
||||
it("path: items but no 'Como ficou' -> the list stays and the button is not repeated", () => {
|
||||
render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: null,
|
||||
capabilities,
|
||||
}}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(capabilities[0])).toBeVisible();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no items and no 'Como ficou' -> renders nothing", () => {
|
||||
const { container } = render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: null,
|
||||
}}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { primaryButton } from "@/app/components/button-styles";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { hasCaseMedia } from "./CaseMediaSection";
|
||||
|
||||
/**
|
||||
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam)
|
||||
* e, de novo, o botão de contato, para quem acabou de ver o resultado não precisar rolar até o fim da página.
|
||||
* Sem itens, só o botão (e só quando há "Como ficou"); sem itens e sem mídia, a seção não aparece.
|
||||
*/
|
||||
export function CaseCapabilitiesSection({
|
||||
caseStudy,
|
||||
isClient,
|
||||
}: {
|
||||
caseStudy: Pick<
|
||||
CaseStudyContent,
|
||||
"id" | "title" | "demoUrl" | "capabilities"
|
||||
>;
|
||||
isClient: boolean;
|
||||
}) {
|
||||
const capabilities = caseStudy.capabilities ?? [];
|
||||
const showContact = hasCaseMedia(caseStudy);
|
||||
if (capabilities.length === 0 && !showContact) return null;
|
||||
|
||||
return (
|
||||
<section className="ds-section" aria-labelledby="case-capabilities">
|
||||
<div className="ds-section-shell">
|
||||
{capabilities.length > 0 ? (
|
||||
<>
|
||||
<h2 id="case-capabilities" className="ds-card-title !mt-0">
|
||||
{isClient
|
||||
? "O que o cliente passou a poder fazer"
|
||||
: "O que dá para fazer com ele"}
|
||||
</h2>
|
||||
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
|
||||
{capabilities.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<h2 id="case-capabilities" className="sr-only">
|
||||
Fale sobre o seu projeto
|
||||
</h2>
|
||||
)}
|
||||
{showContact ? (
|
||||
<div className="mt-sp-8">
|
||||
<WhatsAppLink
|
||||
service={similarProjectSubject(caseStudy.title)}
|
||||
placement="case_after_media"
|
||||
className={primaryButton}
|
||||
>
|
||||
{buttonLabels.contact}
|
||||
</WhatsAppLink>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getRelatedServices } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { CaseContactSection } from "./CaseContactSection";
|
||||
|
||||
/**
|
||||
@@ -25,12 +26,11 @@ describe("CaseContactSection", () => {
|
||||
name: "Quer um projeto parecido com este?",
|
||||
}),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
});
|
||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre um projeto parecido com o ${sev.title}.`,
|
||||
`Quero conversar sobre ${similarProjectSubject(sev.title)}.`,
|
||||
);
|
||||
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
@@ -47,17 +47,16 @@ describe("CaseContactSection", () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Tem um problema parecido? Vamos conversar.",
|
||||
});
|
||||
// a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome
|
||||
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre um projeto parecido com o ${nutri.title}.`,
|
||||
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
||||
);
|
||||
expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
name: /Tem um problema parecido|Conversar sobre um projeto parecido/,
|
||||
}),
|
||||
).toBeNull();
|
||||
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua
|
||||
|
||||
@@ -4,23 +4,18 @@ import {
|
||||
} from "@/app/components/button-styles";
|
||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { CaseRelatedServices } from "./CaseRelatedServices";
|
||||
|
||||
/**
|
||||
* Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido.
|
||||
* O assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`.
|
||||
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
|
||||
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
|
||||
* (e no botão do cabeçalho): `similarProjectSubject`.
|
||||
*/
|
||||
const contactCopy = {
|
||||
client: {
|
||||
title: "Quer um projeto parecido com este?",
|
||||
action: "Conversar sobre um projeto parecido",
|
||||
},
|
||||
lab: {
|
||||
title: "Fale sobre o seu caso",
|
||||
action: "Tem um problema parecido? Vamos conversar.",
|
||||
},
|
||||
client: { title: "Quer um projeto parecido com este?" },
|
||||
lab: { title: "Fale sobre o seu caso" },
|
||||
} as const;
|
||||
|
||||
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */
|
||||
@@ -43,7 +38,7 @@ export function CaseContactSection({
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[60ch]">
|
||||
Conte o que você precisa. A conversa começa direto no WhatsApp, sem
|
||||
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
|
||||
formulário.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
@@ -52,7 +47,7 @@ export function CaseContactSection({
|
||||
placement="case_detail"
|
||||
className={primaryButton}
|
||||
>
|
||||
{copy.action}
|
||||
{buttonLabels.contact}
|
||||
</WhatsAppLink>
|
||||
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||
Ver outros cases
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { secondaryButtonDark } from "@/app/components/button-styles";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
import { CaseMedia } from "./CaseMedia";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
@@ -45,7 +45,8 @@ export function CaseMediaSection({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site {caseStudy.title}
|
||||
{buttonLabels.site}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user