feat(servicos): topo, entregáveis e cases por serviço, assunto próprio no WhatsApp e títulos curtos
This commit is contained in:
1 parent
cf360041bd
commit
2fd8b063d9
33 files changed
+1578
-300
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import { SEO_IDS } from "@/lib/seo";
|
||||
import {
|
||||
buttonLabels,
|
||||
@@ -14,11 +15,9 @@ import {
|
||||
import ServicesIndexPage from "../page";
|
||||
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
|
||||
|
||||
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
|
||||
jest.mock("next/navigation", () => ({
|
||||
notFound: jest.fn(),
|
||||
usePathname: () => "/servicos",
|
||||
}));
|
||||
beforeEach(() => {
|
||||
setNavigation({ pathname: "/servicos" });
|
||||
});
|
||||
|
||||
async function renderService(slug: string) {
|
||||
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
|
||||
@@ -117,7 +116,13 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
||||
for (const cta of primaryCtas) {
|
||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||
expect(destination.origin).toBe("https://wa.me");
|
||||
expect(destination.searchParams.get("text")).toContain(service.navLabel);
|
||||
// o assunto da mensagem é o `whatsappSubject` do serviço, em minúsculas, e não o nome com maiúscula
|
||||
expect(destination.searchParams.get("text")).toContain(
|
||||
`Quero conversar sobre ${service.whatsappSubject}.`,
|
||||
);
|
||||
expect(destination.searchParams.get("text")).toContain(
|
||||
"uma consultoria em Next.js",
|
||||
);
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||
}
|
||||
@@ -236,9 +241,13 @@ it("fills the hub hero with a mosaic of three client posters, never opening with
|
||||
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
|
||||
const posters = Array.from(hero?.querySelectorAll("img") ?? []);
|
||||
expect(posters).toHaveLength(3);
|
||||
// a ordem é a de /cases (a vitrine), sem a SEV, que lidera a lista mas não o mosaico
|
||||
const mosaicCases = clientCasesForListing.filter(
|
||||
({ id }) => id !== "sev-modaintima",
|
||||
);
|
||||
for (const [index, poster] of posters.entries()) {
|
||||
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
|
||||
`/videos/${clientCasesForListing[index].id}/desktop.webp`,
|
||||
`/videos/${mosaicCases[index].id}/desktop.webp`,
|
||||
);
|
||||
expect(poster).toHaveAttribute("alt", "");
|
||||
}
|
||||
@@ -406,19 +415,33 @@ it("shows the client cases first, right after the hero, with the first cover in
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// o case da capa (Lavação 047) é citado pela legenda dela; os cartões trazem os demais, com os de cliente à frente
|
||||
// o case da capa (Lavação 047) é citado pela legenda dela; "Onde já apliquei isso" traz a SEV como segundo trabalho
|
||||
const caseLinks = caseCardLinks(container);
|
||||
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||
getRelatedCases(siteService.relatedCaseIds)
|
||||
.filter(({ id }) => id !== siteService.heroCase?.id)
|
||||
.map(({ id }) => `/cases/${id}`),
|
||||
);
|
||||
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/cases/sc-plus",
|
||||
"/cases/as-servicos",
|
||||
"/cases/sev-modaintima",
|
||||
]);
|
||||
// SC Plus e AS Serviços saem dali e vão para o bloco menor "Também faço sites institucionais"
|
||||
const alsoHeading = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Também faço sites institucionais",
|
||||
});
|
||||
expect(isBefore(casesHeading, alsoHeading)).toBe(true);
|
||||
const alsoBlock = alsoHeading.closest("section") as HTMLElement;
|
||||
expect(
|
||||
within(alsoBlock)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual(["/cases/sc-plus", "/cases/as-servicos"]);
|
||||
expect(
|
||||
within(alsoBlock).getByText(siteService.caseRelevance["sc-plus"]),
|
||||
).toBeVisible();
|
||||
// sem capa no bloco menor
|
||||
expect(alsoBlock.querySelector("img")).toBeNull();
|
||||
expect(
|
||||
getRelatedCases(siteService.relatedCaseIds).map(({ id }) => id),
|
||||
).toEqual(["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"]);
|
||||
|
||||
// pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde, e não a SEV
|
||||
// pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde
|
||||
const hero = container.querySelector("section");
|
||||
const heroFrame = hero?.querySelector(".ds-bframe");
|
||||
expect(heroFrame).not.toBeNull();
|
||||
@@ -427,7 +450,11 @@ it("shows the client cases first, right after the hero, with the first cover in
|
||||
"Captura de tela do site Lavação 047",
|
||||
);
|
||||
expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull();
|
||||
expect(container.textContent).not.toMatch(/SEV Moda Íntima/);
|
||||
// a SEV tem cartão (segundo trabalho), mas não vira a capa do topo
|
||||
expect(hero?.textContent).not.toMatch(/SEV Moda Íntima/);
|
||||
expect(caseCardLinks(container)[0].closest("article")).toHaveTextContent(
|
||||
siteService.caseRelevance["sev-modaintima"],
|
||||
);
|
||||
// a legenda da capa cita o case com a frase de relação e o botão "Ver case"
|
||||
const caption = hero?.querySelector("figcaption");
|
||||
expect(caption).toHaveTextContent("Lavação 047");
|
||||
@@ -437,7 +464,7 @@ it("shows the client cases first, right after the hero, with the first cover in
|
||||
name: "Ver case Lavação 047",
|
||||
}),
|
||||
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||
// a mesma capa não aparece duas vezes: o case de destaque não ganha cartão, e os outros mantêm a capa deles
|
||||
// a mesma capa não aparece duas vezes: o case de destaque não ganha cartão; a SEV mantém a dela e os do bloco menor não têm capa
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
"img[alt='Captura de tela do site Lavação 047']",
|
||||
@@ -447,7 +474,150 @@ it("shows the client cases first, right after the hero, with the first cover in
|
||||
container.querySelectorAll(
|
||||
"img[alt='Captura de tela do site SC Plus Aquicultura']",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("repeats on the two offer pages the 'O que você recebe' and the 'Primeiro passo' of the offer, from the same source as the home", async () => {
|
||||
for (const [slug, offer] of [
|
||||
["criacao-de-sites", serviceOffers[0]],
|
||||
["engenharia-de-software", serviceOffers[1]],
|
||||
] as const) {
|
||||
const { container, unmount } = await renderService(slug);
|
||||
|
||||
const receives = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que você recebe",
|
||||
});
|
||||
const block = receives.closest("section") as HTMLElement;
|
||||
expect(
|
||||
within(block)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.textContent),
|
||||
).toEqual(offer.deliverables);
|
||||
const firstStep = within(block).getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Primeiro passo",
|
||||
});
|
||||
expect(isBefore(receives, firstStep)).toBe(true);
|
||||
expect(within(block).getByText(offer.firstStep)).toBeVisible();
|
||||
// vem depois da prova e antes de "Para quem faz sentido"
|
||||
expect(
|
||||
isBefore(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Onde já apliquei isso",
|
||||
}),
|
||||
receives,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBefore(
|
||||
receives,
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Para quem faz sentido",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// o bloco não traz botão de contato próprio: o do topo e o do fim bastam
|
||||
expect(within(block).queryByRole("link")).toBeNull();
|
||||
expect(container.textContent).not.toMatch(/\d+\s?dias|R\$/);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a single list of deliverables per page: the offer pages drop 'O que podemos entregar', the others keep it", async () => {
|
||||
for (const slug of ["criacao-de-sites", "engenharia-de-software"]) {
|
||||
const { unmount } = await renderService(slug);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "O que podemos entregar" }),
|
||||
).toBeNull();
|
||||
// "Para quem faz sentido" continua, sozinho
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
for (const slug of [
|
||||
"consultoria-nextjs",
|
||||
"tauri-rust",
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]) {
|
||||
const { unmount } = await renderService(slug);
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "O que podemos entregar" }),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves 'O que você recebe' and 'Primeiro passo' out of the services that are not an offer", async () => {
|
||||
for (const slug of [
|
||||
"consultoria-nextjs",
|
||||
"tauri-rust",
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]) {
|
||||
const { unmount } = await renderService(slug);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "O que você recebe" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Primeiro passo" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("heading", {
|
||||
name: "Também faço sites institucionais",
|
||||
}),
|
||||
).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the systems proof with the Apolo Energia block, as the résumé says it, and a subtitle that starts from the pain", async () => {
|
||||
const { container } = await renderService("engenharia-de-software");
|
||||
|
||||
const proof = container.querySelector<HTMLElement>("#casos-relacionados");
|
||||
if (!proof) throw new Error("Missing proof block");
|
||||
// o bloco da Apolo vem no topo da prova, antes dos cartões de case
|
||||
const note = within(proof).getByText(/Apolo Energia/);
|
||||
expect(note).toHaveTextContent(
|
||||
"aplicações web e mobile para rotinas internas, APIs e painéis de operação",
|
||||
);
|
||||
expect(note.textContent).toMatch(/uso interno/);
|
||||
expect(isBefore(note, caseCardLinks(container)[0])).toBe(true);
|
||||
// o subtítulo parte da dor: planilha e retrabalho
|
||||
const subtitle = screen.getByText(/planilhas, mensagens e retrabalho/);
|
||||
expect(isBefore(screen.getByRole("heading", { level: 1 }), subtitle)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the offer name, not the old label, in the breadcrumb, the title and the WhatsApp message of every service", async () => {
|
||||
for (const { slug, navLabel, whatsappSubject } of servicePages) {
|
||||
const { unmount } = await renderService(slug);
|
||||
|
||||
const trail = within(
|
||||
screen.getByRole("navigation", { name: "Trilha de navegação" }),
|
||||
).getAllByRole("link");
|
||||
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
|
||||
for (const cta of screen.getAllByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
})) {
|
||||
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
|
||||
"text",
|
||||
);
|
||||
expect(text).toContain(`Quero conversar sobre ${whatsappSubject}.`);
|
||||
expect(text).not.toMatch(
|
||||
/Sites profissionais|Sites, sistemas e aplicativos/,
|
||||
);
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => {
|
||||
@@ -674,7 +844,8 @@ it("says in one sentence, per case, why it matters to the service", async () =>
|
||||
container.querySelectorAll(`article a[href="/cases/${id}"]`),
|
||||
).toHaveLength(0);
|
||||
} else {
|
||||
const card = screen.getByText(sentence).closest("article");
|
||||
// o cartão de "Onde já apliquei isso" ou a linha do bloco menor "Também faço sites institucionais"
|
||||
const card = screen.getByText(sentence).closest("article, li");
|
||||
expect(card).toContainElement(link);
|
||||
}
|
||||
}
|
||||
@@ -715,13 +886,8 @@ it("cites the Apolo Energia experience in text on the IoT and architecture pages
|
||||
}
|
||||
});
|
||||
|
||||
it("does not mention Apolo Energia on the other service pages", async () => {
|
||||
for (const slug of [
|
||||
"criacao-de-sites",
|
||||
"engenharia-de-software",
|
||||
"consultoria-nextjs",
|
||||
"tauri-rust",
|
||||
]) {
|
||||
it("does not mention Apolo Energia on the other service pages (the systems page cites it, tested above)", async () => {
|
||||
for (const slug of ["criacao-de-sites", "consultoria-nextjs", "tauri-rust"]) {
|
||||
const { container, unmount } = await renderService(slug);
|
||||
expect(container.textContent).not.toMatch(/Apolo/);
|
||||
unmount();
|
||||
|
||||
Reference in new issue
Block a user