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:
Evandro Fonseca Junior committed 2026-10-14 19:14:46 -03:00
1 parent e63f3c2ebd
commit fc44a69fb3
41 files changed
+2486 -550

No files matched your search

+5 -1
View File
@@ -7,7 +7,11 @@ import CasePage from "./page";
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
*/
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(),
usePathname: () => "/cases",
}));
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
+8 -5
View File
@@ -10,10 +10,12 @@ import CasePage, { generateMetadata } from "./page";
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
*/
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
usePathname: () => "/cases",
}));
jest.mock("../_components/case-media", () => {
@@ -125,11 +127,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
expect(
screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
}),
).toBeVisible();
// o NutriScan tem "Como ficou": o botão de contato vem depois dele e de novo no fecho, com o mesmo rótulo
const contacts = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contacts).toHaveLength(2);
for (const contact of contacts) expect(contact).toBeVisible();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases");
+132 -44
View File
@@ -1,9 +1,20 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content";
import {
buttonLabels,
caseStudies,
clientCasesForListing,
getRelatedServices,
siteUrl,
} from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(),
usePathname: () => "/cases",
}));
const clientCaseIds = [
"sev-modaintima",
@@ -50,32 +61,33 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
}
/**
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case.
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma
* frase nos dois, a do cabeçalho: "um projeto parecido com o {título}".
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Com "Como ficou" na página, o botão aparece duas vezes: logo depois dele e no fecho.
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
function expectContactAndNoDeadLinks(
caseStudy: { id: string; title: string },
expectedButtons = 1,
) {
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const isClient = clientCaseIds.includes(caseStudy.id);
const cta = screen.getByRole("link", {
name: isClient
? "Conversar sobre um projeto parecido"
: "Tem um problema parecido? Vamos conversar.",
});
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
`um projeto parecido com o ${caseStudy.title}`,
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
// limão preenchido só no contato: o botão de play e os demais não o usam
expect(cta.className).toContain("bg-[var(--slash)]");
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
expect(ctas).toHaveLength(expectedButtons);
for (const cta of ctas) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
similarProjectSubject(caseStudy.title),
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
// limão preenchido só no contato: o botão de play e os demais não o usam
expect(cta.className).toContain("bg-[var(--slash)]");
}
}
it("exposes the eight public cases, the two new client cases among them", async () => {
@@ -118,6 +130,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
"O problema",
"O que foi feito",
"Como ficou",
"O que o cliente passou a poder fazer",
"Quer um projeto parecido com este?",
];
const sectionHeadings = sectionNames.map((name) =>
@@ -158,7 +171,27 @@ it("renders a client case in the sales order, with media, contact and collapsed
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expectContactAndNoDeadLinks(caseStudy);
// o botão de contato se repete logo depois de "Como ficou", dentro da seção das capacidades
expectContactAndNoDeadLinks(caseStudy, 2);
const capabilitiesSection = sectionHeadings[4].closest("section");
expect(capabilitiesSection).toContainElement(
within(capabilitiesSection as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
}),
);
for (const capability of caseStudy.capabilities ?? []) {
expect(
within(capabilitiesSection as HTMLElement).getByText(capability),
).toBeVisible();
}
expect(
isBefore(
sectionHeadings[3],
within(capabilitiesSection as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
}),
),
).toBe(true);
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
const details = container.querySelector("details");
@@ -260,7 +293,8 @@ it("renders the two new client cases from the portfolio data", async () => {
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expectContactAndNoDeadLinks(caseStudy);
// com "Como ficou" na página, o contato aparece logo depois dele e de novo no fecho
expectContactAndNoDeadLinks(caseStudy, 2);
unmount();
}
});
@@ -289,24 +323,71 @@ it("labels a laboratory case, skips the media block and shows its service as a c
expectContactAndNoDeadLinks(caseStudy);
});
it("turns the contact of a laboratory case into the 'problema parecido' invitation", async () => {
it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invitation, with the same button label as a client case", async () => {
for (const id of labCaseIds) {
const { unmount } = await renderCase(id);
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
}),
).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
expectContactAndNoDeadLinks(getCase(id));
// a repetição do botão só vem junto de "Como ficou"
const hasMedia = screen.queryByRole("heading", { name: "Como ficou" });
expectContactAndNoDeadLinks(getCase(id), hasMedia ? 2 : 1);
// o laboratório não tem "o que o cliente passou a poder fazer"
expect(
screen.queryByRole("heading", {
name: "O que o cliente passou a poder fazer",
}),
).toBeNull();
unmount();
}
});
it("writes what the client can do now right after 'Como ficou', then repeats the contact button, for every client case", async () => {
for (const id of clientCaseIds) {
const caseStudy = getCase(id);
const { unmount } = await renderCase(id);
const media = screen.getByRole("heading", { level: 2, name: "Como ficou" });
const capabilities = screen.getByRole("heading", {
level: 2,
name: "O que o cliente passou a poder fazer",
});
// a seção vem logo depois da mídia, sem outra seção no meio
const mediaSection = media.closest("section");
const capabilitiesSection = capabilities.closest("section");
expect(mediaSection?.nextElementSibling).toBe(capabilitiesSection);
const items = within(capabilitiesSection as HTMLElement)
.getAllByRole("listitem")
.map((item) => item.textContent);
expect(items).toEqual(caseStudy.capabilities);
expect(items.length).toBeGreaterThanOrEqual(2);
expect(items.length).toBeLessThanOrEqual(3);
// sem número inventado
expect(items.join(" ")).not.toMatch(/\d/);
const button = within(capabilitiesSection as HTMLElement).getByRole(
"link",
{ name: buttonLabels.contact },
);
expect(isBefore(media, button)).toBe(true);
unmount();
}
// a frase do dono da Lavação 047 vem dos dados do projeto
await renderCase("lavacao-047");
expect(
screen.getByText(
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
),
).toBeVisible();
});
it("shows the author's role and no site button for a client project without a public site", async () => {
const caseStudy = getCase("as-servicos");
expect(caseStudy.demoUrl).toBeNull();
@@ -361,8 +442,8 @@ it("shows the result section only when the case has real data", async () => {
});
it("shows at most two related services, as cards under the contact button", async () => {
// sev-modaintima tem quatro serviços ligados; só os dois primeiros viram cards
const sev = getCase("sev-modaintima");
// lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
const sev = getCase("lavacao-047");
const all = getRelatedServices(sev.id);
expect(all.length).toBeGreaterThan(2);
const { unmount } = await renderCase(sev.id);
@@ -382,16 +463,16 @@ it("shows at most two related services, as cards under the contact button", asyn
expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
const contact = screen.getByRole("link", {
name: "Conversar sobre um projeto parecido",
});
expect(isBefore(contact, cards[0])).toBe(true);
// o botão do fecho (o último) vem antes dos cartões
const contacts = screen.getAllByRole("link", { name: buttonLabels.contact });
expect(isBefore(contacts[contacts.length - 1], cards[0])).toBe(true);
unmount();
// sc-plus tem dois; as-servicos, um
// sc-plus tem dois; a SEV e a AS Serviços, um cada (a SEV só aparece na página de sistemas)
for (const [id, expected] of [
["sc-plus", 2],
["as-servicos", 1],
["sev-modaintima", 1],
] as const) {
const { unmount: unmountCase } = await renderCase(id);
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
@@ -401,7 +482,7 @@ it("shows at most two related services, as cards under the contact button", asyn
.slice(0, 2)
.map(({ slug }) => `/servicos/${slug}`),
);
expectContactAndNoDeadLinks(getCase(id));
expectContactAndNoDeadLinks(getCase(id), 2);
unmountCase();
}
});
@@ -554,9 +635,9 @@ it("gives the case page the same shell as the home, with no narrow max-width", a
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(
/foi feito para empresas/i,
);
expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
// o título e o subtítulo não repetem "Veja o que já"
expect(heading.nextElementSibling).not.toHaveTextContent(/Veja o que já/);
expect(container.textContent).not.toMatch(/Selected work/i);
const clientHeading = screen.getByRole("heading", {
@@ -571,8 +652,11 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
expect(isBefore(clientHeading, labHeading)).toBe(true);
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible();
// cases de cliente: capa (pôster) em moldura de navegador e botão do case em contorno
const clientLinks = clientCaseIds.map((id) => {
// cases de cliente, com a SEV por último: capa (pôster) em moldura de navegador e botão "Ver case" em contorno
const listedIds = clientCasesForListing.map(({ id }) => id);
expect([...listedIds].sort()).toEqual([...clientCaseIds].sort());
expect(listedIds[listedIds.length - 1]).toBe("sev-modaintima");
const clientLinks = listedIds.map((id) => {
const card = container
.querySelector(`a[href="/cases/${id}"]`)
?.closest("article");
@@ -583,14 +667,18 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
"alt",
`Captura de tela do site ${getCase(id).title}`,
);
return within(card).getByRole("link", { name: "Ver o case" });
return within(card).getByRole("link", {
name: `${buttonLabels.case} ${getCase(id).title}`,
});
});
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
clientCaseIds.map((id) => `/cases/${id}`),
listedIds.map((id) => `/cases/${id}`),
);
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
const labLinks = screen.getAllByRole("link", { name: /^Ver o projeto / });
const labLinks = within(
labHeading.closest("section") as HTMLElement,
).getAllByRole("link", { name: /^Ver case / });
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`),
);
@@ -603,7 +691,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
const row = link.closest("li");
const caseStudy = getCase(labCaseIds[index]);
expect(isBefore(labHeading, link)).toBe(true);
expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`);
expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
expect(
within(row as HTMLElement).getByRole("heading", {
level: 3,
@@ -627,7 +715,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
expect(hrefs).toContain("/servicos");
const cta = screen.getByRole("link", { name: "Conversar sobre meu projeto" });
const cta = screen.getByRole("link", { name: buttonLabels.contact });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
+3
View File
@@ -7,6 +7,7 @@ import {
buildPageSocialMetadata,
} from "@/lib/seo";
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
import { CaseCapabilitiesSection } from "../_components/CaseCapabilitiesSection";
import { CaseContactSection } from "../_components/CaseContactSection";
import { CaseHero } from "../_components/CaseHero";
import { CaseMediaSection } from "../_components/CaseMediaSection";
@@ -136,6 +137,8 @@ export default async function CasePage({ params }: CasePageProps) {
<CaseMediaSection caseStudy={caseStudy} />
<CaseCapabilitiesSection caseStudy={caseStudy} isClient={isClient} />
<CaseResultSection result={caseStudy.result} />
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />
@@ -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
+8 -13
View File
@@ -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
+3 -2
View File
@@ -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}
+13 -19
View File
@@ -1,11 +1,11 @@
import { render } from "@testing-library/react";
import type { ComponentProps } from "react";
import { clientCases } from "@/lib/site-content";
import { clientCasesForListing as clientCases } from "@/lib/site-content";
import CasesPage from "./page";
/**
* Carregamento da lista de cases: o primeiro pôster do mosaico do topo (a maior imagem acima da dobra) carrega na hora
* e com prioridade alta, as capas dos cartões são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
* Carregamento da lista de cases: sem mosaico no topo, a capa do primeiro cartão (a maior imagem acima da dobra) carrega
* na hora e com prioridade alta, as outras capas são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
*/
type LinkProps = ComponentProps<typeof import("next/link").default>;
@@ -40,26 +40,20 @@ function coverImages(container: HTMLElement) {
}
describe("CasesPage: carregamento das capas", () => {
it("loads the first mosaic poster at once with high priority, and every other image lazily", () => {
it("loads the first card cover at once with high priority, and every other image lazily", () => {
const { container } = render(<CasesPage />);
const mosaic = Array.from(
container.querySelectorAll<HTMLImageElement>("section [aria-hidden] img"),
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
const decorative = Array.from(
container.querySelectorAll<HTMLImageElement>("img"),
).filter((image) => image.getAttribute("alt") === "");
const [main, ...side] = mosaic;
expect(main).toHaveAttribute("loading", "eager");
expect(main).toHaveAttribute("fetchpriority", "high");
// a largura real do pôster, e não 100vw (que baixava 750 px para 330 px)
expect(main.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
expect(side.length).toBeGreaterThan(0);
for (const image of side) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
}
expect(decorative).toHaveLength(0);
const covers = coverImages(container);
expect(covers.length).toBeGreaterThan(0);
for (const image of covers) {
const [first, ...others] = coverImages(container);
expect(first).toHaveAttribute("loading", "eager");
expect(first).toHaveAttribute("fetchpriority", "high");
expect(others.length).toBeGreaterThan(0);
for (const image of others) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
}
+73 -38
View File
@@ -1,5 +1,9 @@
import { render, screen, within } from "@testing-library/react";
import { clientCases } from "@/lib/site-content";
import {
buttonLabels,
clientCasesForListing,
labCases,
} from "@/lib/site-content";
import { getCaseMedia } from "./_components/case-media";
import CasesPage from "./page";
@@ -32,7 +36,7 @@ function clientCard(container: HTMLElement, id: string) {
describe("CasesPage: cases de cliente", () => {
it("path: client case without a desktop capture -> card keeps text and links but no cover", () => {
const [withoutCapture, withCapture] = clientCases;
const [withoutCapture, withCapture] = clientCasesForListing;
mockedGetCaseMedia.mockImplementation((id, device) =>
id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device),
);
@@ -42,14 +46,12 @@ describe("CasesPage: cases de cliente", () => {
const bare = clientCard(container, withoutCapture.id);
expect(bare.querySelector(".ds-bframe")).toBeNull();
expect(bare.querySelector("img")).toBeNull();
expect(
within(bare).queryByRole("link", {
name: `Ver o case ${withoutCapture.title}`,
}),
).toBeNull();
expect(bare.querySelector("a[aria-hidden='true']")).toBeNull();
expect(within(bare).getByText(withoutCapture.headline)).toBeVisible();
expect(
within(bare).getByRole("link", { name: "Ver o case" }),
within(bare).getByRole("link", {
name: `${buttonLabels.case} ${withoutCapture.title}`,
}),
).toHaveAttribute("href", `/cases/${withoutCapture.id}`);
// os outros cases de cliente continuam com a capa
@@ -58,55 +60,88 @@ describe("CasesPage: cases de cliente", () => {
"alt",
`Captura de tela do site ${withCapture.title}`,
);
expect(screen.getAllByRole("link", { name: "Ver o case" })).toHaveLength(
clientCases.length,
});
it("path: every case -> one 'Ver case' button, named after its project, and no 'Ver o case' or 'Ler case'", () => {
render(<CasesPage />);
const buttons = screen.getAllByRole("link", { name: /^Ver case / });
expect(buttons).toHaveLength(
clientCasesForListing.length + labCases.length,
);
for (const link of buttons) {
expect(link.textContent?.startsWith(buttonLabels.case)).toBe(true);
}
expect(
screen.queryByRole("link", { name: /Ver o case|Ler case|Ver o projeto/ }),
).toBeNull();
});
it("path: contact -> 'Falar sobre meu projeto', and the list of clients opens with Lavação 047 and ends with the SEV", () => {
const { container } = render(<CasesPage />);
expect(
screen.getByRole("link", { name: buttonLabels.contact }),
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
const clientTitles = Array.from(
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
).map((kicker) => kicker.textContent);
expect(clientTitles).toEqual(
clientCasesForListing.map(({ title }) => title),
);
expect(clientTitles[0]).toBe("Lavação 047");
expect(clientTitles[clientTitles.length - 1]).toBe("SEV Moda Íntima");
});
});
describe("CasesPage: topo com mosaico de pôsteres", () => {
it("path: captures found -> two columns with three posters of the other clients, not the first card's", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(hero?.className).not.toContain("md:!pb-sp-12");
const posters = Array.from(hero?.querySelectorAll("img") ?? []).map(
(image) => decodeURIComponent(image.getAttribute("src") ?? ""),
);
expect(posters).toHaveLength(3);
expect(posters.join(" ")).not.toContain(clientCases[0].id);
for (const [index, { id }] of clientCases.slice(1).entries()) {
expect(posters[index]).toContain(`/videos/${id}/desktop.webp`);
}
});
it("path: no capture for the other clients -> no mosaic, a lower one-column strip, and the first card cover takes the priority", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === clientCases[0].id ? actualGetCaseMedia(id, device) : undefined,
);
describe("CasesPage: topo sem mosaico", () => {
it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toBeNull();
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(hero?.className).toContain("md:!pb-sp-12");
const firstCover = clientCard(container, clientCases[0].id).querySelector(
".ds-bframe img",
// o título e o subtítulo não repetem a mesma frase
const heading = within(hero as HTMLElement).getByRole("heading", {
level: 1,
});
expect(heading).toHaveTextContent("Veja o que já fiz para empresas.");
expect(hero?.textContent?.match(/Veja o que já/g)).toHaveLength(1);
expect(hero?.textContent).not.toMatch(/\bdireto\b/);
});
it("path: no capture for the other clients -> still no mosaic, and the first card cover takes the priority", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === clientCasesForListing[0].id
? actualGetCaseMedia(id, device)
: undefined,
);
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toBeNull();
const firstCover = clientCard(
container,
clientCasesForListing[0].id,
).querySelector(".ds-bframe img");
expect(firstCover).toHaveAttribute("loading", "eager");
expect(firstCover).toHaveAttribute("fetchpriority", "high");
});
});
describe("CasesPage: prioridade da capa do primeiro cartão", () => {
it("path: mosaic at the top -> the first card cover stays lazy, the priority is the mosaic's first poster", () => {
it("path: no mosaic -> the first card cover is the page's biggest image, so it loads at once with high priority; the others stay lazy", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toHaveAttribute("loading", "eager");
for (const { id } of clientCases) {
const [first, ...others] = clientCasesForListing;
const firstCover = clientCard(container, first.id).querySelector(
".ds-bframe img",
);
expect(firstCover).toHaveAttribute("loading", "eager");
expect(firstCover).toHaveAttribute("fetchpriority", "high");
for (const { id } of others) {
const cover = clientCard(container, id).querySelector(".ds-bframe img");
expect(cover).toHaveAttribute("loading", "lazy");
expect(cover).not.toHaveAttribute("fetchpriority");
+24 -25
View File
@@ -12,21 +12,24 @@ import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import {
buttonLabels,
type CaseStudyContent,
clientCases,
clientCasesForListing,
labCases,
siteUrl,
} from "@/lib/site-content";
import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink";
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
import { getMosaicPosters, PosterMosaic } from "./_components/PosterMosaic";
import { compactHero } from "./_components/hero-styles";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas";
const pageDescription =
"Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
const allCases = [...clientCases, ...labCases];
"Cases de sites e sistemas para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
/** Abertura da página: o que o visitante encontra nos cases, sem repetir a frase do título. */
const pageLead =
"Abra um case para ver o problema do cliente, o que foi feito e como o site ficou, da loja virtual ao site que o dono edita sozinho.";
const allCases = [...clientCasesForListing, ...labCases];
export const metadata: Metadata = {
title: pageTitle,
@@ -45,7 +48,7 @@ function ClientCaseCard({
priority = false,
}: {
caseStudy: CaseStudyContent;
/** Capa que abre a página: só quando não há mosaico no topo, a capa passa a ser a maior imagem visível. */
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
priority?: boolean;
}) {
return (
@@ -64,7 +67,8 @@ function ClientCaseCard({
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ver o case
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
@@ -73,7 +77,8 @@ function ClientCaseCard({
target="_blank"
rel="noreferrer"
>
Abrir o site
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
@@ -97,16 +102,14 @@ function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
href={`/cases/${caseStudy.id}`}
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`}
>
Ver o projeto<span className="sr-only"> {caseStudy.title}</span>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
</li>
);
}
export default function CasesPage() {
// o mosaico mostra os outros clientes: o primeiro já aparece, com a capa, no primeiro cartão logo abaixo
const posters = getMosaicPosters(clientCases.slice(1).map(({ id }) => id));
const hasMosaic = posters.length > 0;
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -137,14 +140,11 @@ export default function CasesPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
{/* sem mosaico de pôsteres: os cartões logo abaixo já trazem a capa de cada cliente, e a mesma imagem não aparece duas vezes */}
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMosaic ? "" : compactHero
}`}
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div
className={`ds-section-shell ${hasMosaic ? heroWithMediaGrid : ""}`}
>
<div className="ds-section-shell">
<div className="max-w-[860px]">
<Breadcrumb
items={[
@@ -157,10 +157,9 @@ export default function CasesPage() {
Veja o que já fiz para empresas.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{pageDescription}
{pageLead}
</p>
</div>
<PosterMosaic posters={posters} />
</div>
</section>
@@ -170,11 +169,11 @@ export default function CasesPage() {
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy, index) => (
{clientCasesForListing.map((caseStudy, index) => (
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
priority={index === 0 && !hasMosaic}
priority={index === 0}
/>
))}
</div>
@@ -203,7 +202,7 @@ export default function CasesPage() {
Quer um projeto parecido com algum destes?
</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">
@@ -212,10 +211,10 @@ export default function CasesPage() {
placement="cases_hub_footer"
className={primaryButton}
>
Conversar sobre meu projeto
{buttonLabels.contact}
</WhatsAppLink>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
Ver os serviços
{buttonLabels.allServices}
</HoverPrefetchLink>
</div>
</div>
+13 -9
View File
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
/**
* Testes de caminhos-base de Breadcrumb: o primeiro item não tem separador,
* os seguintes têm, e só o último é a página atual (inclusive quando é o único).
* Testes de caminhos-base de Breadcrumb: a barra de separação vem depois de cada item menos do último (a quebra de linha
* no celular nunca deixa uma linha começando por "/"), e só o último é a página atual (inclusive quando é o único).
*/
describe("Breadcrumb", () => {
it("path: several items -> a separator before every item but the first", () => {
it("path: several items -> a separator after every item but the last, so a wrapped line starts with a name", () => {
const { container } = render(
<Breadcrumb
items={[
@@ -20,15 +20,19 @@ describe("Breadcrumb", () => {
const entries = Array.from(container.querySelectorAll("li"));
expect(entries).toHaveLength(3);
expect(entries[0].querySelector("span[aria-hidden='true']")).toBeNull();
expect(
entries[0].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(
entries[1].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(
entries[2].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(entries[0].className).toBe("");
expect(entries[1].className).toContain("flex");
expect(entries[2].querySelector("span[aria-hidden='true']")).toBeNull();
// o nome vem antes da barra em todos os itens, e nenhum item abre por "/"
for (const entry of entries) {
expect(entry.firstElementChild?.tagName).toBe("A");
expect(entry.textContent?.trimStart().startsWith("/")).toBe(false);
}
expect(entries[0].className).toContain("flex");
});
it("path: a single item -> no separator, and it is the current page", () => {
+7 -6
View File
@@ -3,6 +3,8 @@ import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual.
* Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink).
* A barra vem depois de cada item, menos do último: quando a trilha não cabe numa linha (celular), a quebra cai
* depois da barra e a linha de baixo começa pelo nome, nunca por "/".
*/
export function Breadcrumb({
items,
@@ -11,13 +13,9 @@ export function Breadcrumb({
}) {
return (
<nav aria-label="Trilha de navegação" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-[15px]">
<ol className="flex flex-wrap items-center gap-x-sp-2 gap-y-sp-1 text-[15px]">
{items.map(({ label, href }, index) => (
<li
key={href}
className={index > 0 ? "flex items-center gap-sp-2" : undefined}
>
{index > 0 ? <span aria-hidden="true">/</span> : null}
<li key={href} className="flex items-center gap-sp-2">
<HoverPrefetchLink
aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text"
@@ -25,6 +23,9 @@ export function Breadcrumb({
>
{label}
</HoverPrefetchLink>
{index < items.length - 1 ? (
<span aria-hidden="true">/</span>
) : null}
</li>
))}
</ol>
+90
View File
@@ -0,0 +1,90 @@
import { render, screen, within } from "@testing-library/react";
import { Faq } from "./Faq";
import HomeFaq from "./HomeFaq";
const items = [
{ question: "Primeira pergunta?", answer: "Primeira resposta." },
{
question: "Segunda pergunta?",
answer: "Segunda resposta.",
link: { href: "/ia", label: "Ver a revisão" },
},
{ question: "Sem resposta ainda?", answer: " " },
];
describe("Faq", () => {
it("path: answered questions -> one closed accordion item per question, under a level 2 heading", () => {
const { container } = render(<Faq items={items} />);
expect(
screen.getByRole("heading", { level: 2, name: "Perguntas frequentes" }),
).toBeInTheDocument();
const summaries = Array.from(container.querySelectorAll("summary")).map(
(summary) => summary.textContent?.replace("+", "").trim(),
);
expect(summaries).toEqual(["Primeira pergunta?", "Segunda pergunta?"]);
for (const details of container.querySelectorAll("details")) {
expect(details.open).toBe(false);
}
});
it("path: a question with no answer -> stays out of the page, like on the home", () => {
render(<Faq items={items} />);
expect(screen.queryByText("Sem resposta ainda?")).toBeNull();
});
it("path: a question with a link -> the link sits inside its answer", () => {
render(<Faq items={items} />);
const details = screen
.getByText("Segunda pergunta?")
.closest("details") as HTMLElement;
expect(within(details).getByText("Segunda resposta.")).toBeInTheDocument();
expect(
within(details).getByRole("link", { name: "Ver a revisão" }),
).toHaveAttribute("href", "/ia");
});
it("path: custom title and anchor -> the heading and the section follow them", () => {
const { container } = render(
<Faq items={items} id="antes" title="Antes de contratar" />,
);
const heading = screen.getByRole("heading", { name: "Antes de contratar" });
expect(heading).toHaveAttribute("id", "antes-title");
expect(container.querySelector("section")).toHaveAttribute("id", "antes");
expect(container.querySelector("section")).toHaveAttribute(
"aria-labelledby",
"antes-title",
);
});
it("path: no items, or none with an answer -> renders nothing, not even the heading", () => {
const empty = render(<Faq />);
expect(empty.container).toBeEmptyDOMElement();
empty.unmount();
const unanswered = render(
<Faq items={[{ question: "Pergunta?", answer: "" }]} />,
);
expect(unanswered.container).toBeEmptyDOMElement();
});
it("path: same pattern as the home FAQ -> same summary and details classes", () => {
const shared = render(<Faq items={items} />);
const home = render(
<HomeFaq
items={[{ question: "Primeira pergunta?", answer: "Resposta." }]}
/>,
);
const classOf = (container: HTMLElement, selector: string) =>
container.querySelector(selector)?.className;
for (const selector of ["details", "summary", "summary > span"]) {
expect(classOf(shared.container, selector)).toBe(
classOf(home.container, selector),
);
}
});
});
+73
View File
@@ -0,0 +1,73 @@
import { textLink } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink";
export interface FaqEntry {
question: string;
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
answer: string;
link?: { href: string; label: string };
}
/**
* Perguntas frequentes em acordeão, um formato só no site: pergunta na linha, resposta ao abrir, "+" que gira.
* A home (HomeFaq), as páginas de serviço e a de serviços usam este componente. Só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que o dono não tenha dito.
*/
export function Faq({
items,
id = "faq",
title = "Perguntas frequentes",
eyebrow = "Dúvidas comuns",
}: {
items?: readonly FaqEntry[];
/** Âncora da seção; o título leva `${id}-title`. */
id?: string;
title?: string;
eyebrow?: string;
}) {
const answered = (items ?? []).filter((item) => item.answer.trim() !== "");
if (answered.length === 0) return null;
return (
<section id={id} aria-labelledby={`${id}-title`} className="ds-section">
<div className="ds-section-shell">
<p className="ds-eyebrow">{eyebrow}</p>
<h2
id={`${id}-title`}
className="ds-section-title max-w-[22ch] text-balance"
>
{title}
</h2>
<div className="mt-sp-8 max-w-[860px]">
{answered.map((item) => (
<details
key={item.question}
className="group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
<span
aria-hidden="true"
className="shrink-0 font-mono text-xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</summary>
<div className="pb-sp-5">
<p className="ds-copy max-w-[65ch]">{item.answer}</p>
{item.link ? (
<HoverPrefetchLink
href={item.link.href}
className={`${textLink} mt-sp-2 text-[var(--ink-80)]`}
>
{item.link.label}
</HoverPrefetchLink>
) : null}
</div>
</details>
))}
</div>
</div>
</section>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { trackCvDownload } from "@/lib/analytics";
import { buttonLabels } from "@/lib/site-content";
import CurriculoPage, { metadata } from "./page";
jest.mock("@/lib/analytics", () => ({ trackCvDownload: jest.fn() }));
@@ -43,7 +44,7 @@ it("shows a matching breadcrumb and keeps consulting secondary to hiring", () =>
expect(breadcrumbLinks[1]).toHaveAttribute("href", "/curriculo");
expect(breadcrumbLinks[1]).toHaveAttribute("aria-current", "page");
const hiringLink = screen.getByRole("link", { name: /oportunidade/i });
const hiringLink = screen.getByRole("link", { name: buttonLabels.contact });
const consultingLink = screen.getByRole("link", { name: /consultoria/i });
expect(hiringLink).toHaveAttribute("href", "/#contact");
expect(consultingLink).toHaveAttribute("href", "/servicos");
@@ -71,7 +72,7 @@ it("offers the résumé PDF as a secondary action and measures the download", ()
expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
// limão só para o contato
expect(pdf).not.toHaveClass("ds-link-accent");
expect(screen.getByRole("link", { name: /oportunidade/i })).toHaveClass(
expect(screen.getByRole("link", { name: buttonLabels.contact })).toHaveClass(
"ds-link-accent",
);
+2 -2
View File
@@ -10,7 +10,7 @@ import {
buildPageSocialMetadata,
buildProfilePageJsonLd,
} from "@/lib/seo";
import { siteUrl } from "@/lib/site-content";
import { buttonLabels, siteUrl } from "@/lib/site-content";
import CvDownloadLink from "./CvDownloadLink";
const pageUrl = `${siteUrl}/curriculo`;
@@ -68,7 +68,7 @@ export default function CurriculoPage() {
className="ds-link ds-link-accent"
href="/#contact"
>
Falar sobre uma oportunidade
{buttonLabels.contact}
</HoverPrefetchLink>
<CvDownloadLink className="ds-link ds-link-muted">
Baixar currículo em PDF
@@ -0,0 +1,162 @@
import { render, screen } from "@testing-library/react";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
import { getServicePage } from "@/lib/site-content";
import ServicePage from "./page";
/**
* Testes de caminhos-base da escolha da capa do topo em ServicePage (complemento de page.test.tsx), com as capturas
* gravadas controladas pelo teste (getCaseMedia trocado por um dublê que só "tem" o que o teste diz).
*
* A capa vale quando o serviço tem `heroCase` E existe captura gravada do aparelho pedido (heroCase.device); senão
* entra o `diagram` do serviço, se tiver; sem os dois, o topo vira uma coluna só e mais baixa.
* Caminhos: capa de computador | capa de celular | sem captura do computador | só captura do outro aparelho |
* case da capa que não existe | serviço de esquema, com ou sem captura de qualquer case.
* Quando a capa vale, a legenda dela cita o case e o case não ganha cartão em "Trabalhos parecidos".
*/
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
usePathname: () => "/servicos",
}));
jest.mock("@/app/cases/_components/case-media", () => ({
...jest.requireActual("@/app/cases/_components/case-media"),
getCaseMedia: jest.fn(),
}));
type Capture = readonly [id: string, device: Device];
/** Só as capturas listadas existem no disco. */
function capturesOnDisk(captures: readonly Capture[]) {
jest
.mocked(getCaseMedia)
.mockImplementation((id, device) =>
captures.some(
([knownId, knownDevice]) => knownId === id && knownDevice === device,
)
? getShowcaseAssets(id, device)
: undefined,
);
}
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
const view = render(ui);
const hero = view.container.querySelector("section") as HTMLElement;
return {
...view,
hero,
twoColumns: hero
.querySelector(".ds-section-shell")
?.classList.contains("grid"),
compact: hero.className.includes("md:!pb-sp-12"),
caption: hero.querySelector("figure figcaption"),
cards: Array.from(
view.container.querySelectorAll("#casos-relacionados article"),
).map((card) => card.querySelector(".ds-kicker")?.textContent),
};
}
afterEach(() => {
jest.mocked(getCaseMedia).mockReset();
});
describe("ServicePage: escolha da capa do topo", () => {
it("path: desktop cover with its capture on disk -> browser frame in the hero, the caption cites the case and the case gets no card", async () => {
capturesOnDisk([["lavacao-047", "desktop"]]);
const view = await renderService("criacao-de-sites");
expect(view.hero.querySelector(".ds-bframe")).not.toBeNull();
expect(view.twoColumns).toBe(true);
expect(view.compact).toBe(false);
expect(view.caption).toHaveTextContent("Lavação 047");
expect(view.cards).not.toContain("Lavação 047");
expect(view.cards).toEqual(["SC Plus Aquicultura", "AS Serviços"]);
});
it("path: mobile cover with its capture on disk -> phone frame in the hero, no browser frame", async () => {
capturesOnDisk([["sev-modaintima", "mobile"]]);
const view = await renderService("engenharia-de-software");
expect(view.hero.querySelector(".ds-phone-screen")).not.toBeNull();
expect(view.hero.querySelector(".ds-bframe")).toBeNull();
expect(view.twoColumns).toBe(true);
expect(view.caption).toHaveTextContent("SEV Moda Íntima");
expect(view.cards).toEqual(["Lavação 047"]);
});
it("path: no capture on disk -> no cover, a lower one-column hero, and the case that would be the cover gets its card back", async () => {
capturesOnDisk([]);
const view = await renderService("criacao-de-sites");
expect(view.hero.querySelector("figure")).toBeNull();
expect(view.twoColumns).toBe(false);
expect(view.compact).toBe(true);
expect(screen.queryByRole("img")).toBeNull();
expect(view.cards).toEqual([
"Lavação 047",
"SC Plus Aquicultura",
"AS Serviços",
]);
});
it("path: only the OTHER device was captured (mobile cover, desktop capture) -> no cover, the check follows the device the service asks for", async () => {
capturesOnDisk([["sev-modaintima", "desktop"]]);
const view = await renderService("engenharia-de-software");
expect(view.hero.querySelector("figure")).toBeNull();
expect(view.compact).toBe(true);
expect(view.cards).toEqual(["SEV Moda Íntima", "Lavação 047"]);
expect(getCaseMedia).toHaveBeenCalledWith("sev-modaintima", "mobile");
});
it("path: the cover case does not exist -> same as no capture, no crash", async () => {
capturesOnDisk([["nao-existe", "desktop"]]);
const service = getServicePage("consultoria-nextjs");
if (!service?.heroCase) throw new Error("Missing hero case");
const original = service.heroCase;
service.heroCase = { id: "nao-existe", device: "desktop" };
try {
const view = await renderService("consultoria-nextjs");
expect(view.hero.querySelector("figure")).toBeNull();
expect(view.compact).toBe(true);
} finally {
service.heroCase = original;
}
});
it("path: diagram service -> the diagram, never a cover, even with every capture on disk", async () => {
capturesOnDisk([
["twins-lab", "desktop"],
["twins-lab", "mobile"],
["lavacao-047", "desktop"],
]);
const view = await renderService("integracao-hardware-software-iot");
expect(
screen.getByRole("img", { name: /dispositivo envia as leituras/ }),
).toBeVisible();
expect(view.hero.querySelector(".ds-bframe")).toBeNull();
expect(view.twoColumns).toBe(true);
expect(view.compact).toBe(false);
});
it("path: a service with a cover AND the capture missing never falls back to a diagram it does not have", async () => {
capturesOnDisk([]);
const service = getServicePage("consultoria-nextjs");
const view = await renderService("consultoria-nextjs");
expect(service?.diagram).toBeUndefined();
expect(screen.queryByRole("img", { name: /Esquema|esquema/ })).toBeNull();
expect(view.compact).toBe(true);
});
});
+14 -5
View File
@@ -8,10 +8,12 @@ import ServicePage, { generateMetadata } from "./page";
* Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias.
*/
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
usePathname: () => "/servicos",
}));
beforeEach(() => {
@@ -33,20 +35,27 @@ describe("ServicePage: caminhos que o teste principal não cobre", () => {
expect(notFound).toHaveBeenCalledTimes(1);
});
it("path: service with a FAQ (main path) -> shows every question", async () => {
it("path: service with a FAQ (main path) -> shows every question as a closed accordion item, the same pattern as the home", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq?.length) throw new Error("Missing service FAQ");
await renderService(service.slug);
const { container } = await renderService(service.slug);
expect(
screen.getByRole("heading", { level: 2, name: FAQ_HEADING }),
).toBeVisible();
const summaries = Array.from(
container.querySelectorAll("#faq summary"),
).map((summary) => summary.textContent?.replace("+", "").trim());
expect(summaries).toEqual(service.faq.map(({ question }) => question));
for (const item of service.faq) {
expect(
screen.getByRole("heading", { level: 3, name: item.question }),
).toBeVisible();
const details = screen.getByText(item.question).closest("details");
expect(details).not.toBeNull();
expect(details?.open).toBe(false);
expect(details).toHaveTextContent(item.answer);
}
// um formato só: nenhum cartão de pergunta e resposta
expect(container.querySelector("#faq article")).toBeNull();
});
it("path: service without the faq field -> no FAQ section, the rest of the page stays", async () => {
+301 -64
View File
@@ -1,18 +1,24 @@
import { render, screen, within } from "@testing-library/react";
import { SEO_IDS } from "@/lib/seo";
import {
buttonLabels,
caseStudies,
clientCases,
clientCasesForListing,
getRelatedCases,
getServicePage,
isClientCase,
serviceOffers,
servicePages,
siteUrl,
} from "@/lib/site-content";
import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(),
usePathname: () => "/servicos",
}));
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
@@ -25,6 +31,13 @@ function isBefore(first: Element, second: Element) {
);
}
/** Links "Ver case" dos cartões de "Onde já apliquei isso"; a capa do topo, que também abre o case, fica de fora. */
function caseCardLinks(container: HTMLElement) {
const block = container.querySelector<HTMLElement>("#casos-relacionados");
if (!block) throw new Error("Missing related cases block");
return within(block).getAllByRole("link", { name: /^Ver case / });
}
const service = getServicePage("consultoria-nextjs");
if (!service) throw new Error("Missing representative service");
@@ -97,7 +110,10 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
expect(screen.getByText(detail)).not.toBeVisible();
}
const primaryCtas = screen.getAllByRole("link", { name: /WhatsApp/i });
const primaryCtas = screen.getAllByRole("link", {
name: buttonLabels.contact,
});
expect(primaryCtas).toHaveLength(2);
for (const cta of primaryCtas) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
@@ -106,19 +122,24 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
}
// o case da capa (SC Plus) é citado pela legenda dela, e não ganha cartão: os cartões são os demais cases relacionados
const heroId = service.heroCase?.id;
expect(heroId).toBe("sc-plus");
expect(
container.querySelector("section figcaption a[href='/cases/sc-plus']"),
).not.toBeNull();
const expectedCaseLinks = service.relatedCaseIds
.filter((id) => id !== heroId)
.map((id) => {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing related case ${id}`);
return {
name: `Ler case: ${caseStudy.title}`,
name: `Ver case ${caseStudy.title}`,
href: `/cases/${id}`,
};
})
.sort((a, b) => a.href.localeCompare(b.href));
const internalCaseLinks = screen.getAllByRole("link", {
name: /^Ler case:/,
});
const internalCaseLinks = caseCardLinks(container);
expect(
internalCaseLinks
.map((link) => ({
@@ -208,7 +229,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
]);
});
it("fills the hub hero with a mosaic of three client posters and states the location in one line", () => {
it("fills the hub hero with a mosaic of three client posters, never opening with the SEV, and states the location in one line", () => {
const { container } = render(<ServicesIndexPage />);
const hero = container.querySelector("section");
@@ -217,10 +238,18 @@ it("fills the hub hero with a mosaic of three client posters and states the loca
expect(posters).toHaveLength(3);
for (const [index, poster] of posters.entries()) {
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
`/videos/${clientCases[index].id}/desktop.webp`,
`/videos/${clientCasesForListing[index].id}/desktop.webp`,
);
expect(poster).toHaveAttribute("alt", "");
}
const posterSources = posters.map((poster) =>
decodeURIComponent(poster.getAttribute("src") ?? ""),
);
expect(posterSources[0]).toContain("/videos/lavacao-047/");
expect(posterSources.some((src) => src.includes("sev-modaintima"))).toBe(
false,
);
expect(new Set(posterSources).size).toBe(3);
// só o primeiro, o maior, carrega na hora e com prioridade alta
expect(posters[0]).toHaveAttribute("fetchpriority", "high");
expect(posters[1]).toHaveAttribute("loading", "lazy");
@@ -229,10 +258,9 @@ it("fills the hub hero with a mosaic of three client posters and states the loca
);
expect(container.textContent).not.toMatch(/perto de Garuva/);
// as capas dos projetos, mais abaixo, têm a largura da coluna e carregam preguiçosas
const cover = container.querySelector("#projetos .ds-bframe img");
expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
expect(cover).toHaveAttribute("loading", "lazy");
// nenhum cliente do mosaico se repete mais abaixo: as ofertas citam os cases só por link, sem capa
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(0);
expect(container.querySelectorAll("img")).toHaveLength(3);
});
it("keeps hub cards and structured items aligned", () => {
@@ -256,10 +284,25 @@ it("keeps hub cards and structured items aligned", () => {
expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
);
const visibleCards = itemList.itemListElement.map(({ name }) =>
screen.getByRole("link", { name }),
);
const visibleHrefs = visibleCards.map((link) => link.getAttribute("href"));
// as duas ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
const offerLinks = serviceOffers
.filter(({ href }) => publicServiceHrefs.includes(href))
.map(({ name, href }) => ({
href,
link: screen.getByRole("link", { name: `Ver o serviço ${name}` }),
}));
expect(offerLinks).toHaveLength(2);
for (const { href, link } of offerLinks) {
expect(link).toHaveAttribute("href", href);
}
const otherLinks = itemList.itemListElement
.filter(({ url }) => !offerLinks.some(({ href }) => url.endsWith(href)))
.map(({ name }) => screen.getByRole("link", { name }));
expect(otherLinks).toHaveLength(4);
const visibleHrefs = [
...offerLinks.map(({ link }) => link.getAttribute("href")),
...otherLinks.map((link) => link.getAttribute("href")),
];
expect(visibleHrefs).toHaveLength(6);
expect(new Set(visibleHrefs).size).toBe(6);
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
@@ -272,20 +315,68 @@ it("keeps hub cards and structured items aligned", () => {
});
});
it("shows the client projects with covers on the hub, without disclaimers", () => {
it("puts the three offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => {
const { container } = render(<ServicesIndexPage />);
for (const id of [
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
]) {
const link = container.querySelector(`a[href="/cases/${id}"]`);
const offersHeading = screen.getByRole("heading", {
level: 2,
name: "Três jeitos de começar.",
});
const othersHeading = screen.getByRole("heading", {
level: 2,
name: "Outros serviços",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), offersHeading),
).toBe(true);
expect(isBefore(offersHeading, othersHeading)).toBe(true);
const cards = Array.from(
container.querySelectorAll<HTMLElement>("section article"),
).slice(0, 3);
expect(cards).toHaveLength(3);
for (const [index, offer] of serviceOffers.entries()) {
const card = cards[index];
expect(
link?.closest("article")?.querySelector(".ds-bframe img"),
).not.toBeNull();
within(card).getByRole("heading", { level: 3, name: offer.name }),
).toBeVisible();
for (const item of offer.deliverables) {
expect(within(card).getByText(item)).toBeVisible();
}
expect(
within(card).getByRole("link", { name: `Ver o serviço ${offer.name}` }),
).toHaveAttribute("href", offer.href);
expect(within(card).getByText(offer.proof.label)).toBeVisible();
}
// a prova: Lavação 047 e SEV na oferta do site, a SEV e a Apolo na do sistema, a página /ia na da revisão
expect(
within(cards[0]).getByRole("link", { name: "Ver case Lavação 047" }),
).toHaveAttribute("href", "/cases/lavacao-047");
expect(
within(cards[0]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
within(cards[1]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(within(cards[1]).getByText(/Apolo Energia/)).toBeVisible();
expect(within(cards[2]).queryAllByRole("link")).toHaveLength(1);
expect(
within(cards[2]).getByRole("link", { name: /^Ver o serviço/ }),
).toHaveAttribute("href", "/ia");
});
it("keeps the hub free of case covers, with the SEV only as a proof link in two offers, and without disclaimers", () => {
const { container } = render(<ServicesIndexPage />);
// a SEV aparece só como link de prova nas duas ofertas, nunca como capa ou no mosaico
const sevLinks = container.querySelectorAll(
`a[href="/cases/sev-modaintima"]`,
);
expect(sevLinks).toHaveLength(2);
for (const link of sevLinks) {
expect(link.querySelector("img")).toBeNull();
}
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(0);
expect(container.textContent).not.toMatch(
/sem promessas|Demonstração publicada|limites|indispon/i,
);
@@ -315,72 +406,198 @@ it("shows the client cases first, right after the hero, with the first cover in
),
).toBe(true);
const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ });
// o case da capa (Lavação 047) é citado pela legenda dela; os cartões trazem os demais, com os de cliente à frente
const caseLinks = caseCardLinks(container);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual(
getRelatedCases(siteService.relatedCaseIds).map(({ id }) => `/cases/${id}`),
getRelatedCases(siteService.relatedCaseIds)
.filter(({ id }) => id !== siteService.heroCase?.id)
.map(({ id }) => `/cases/${id}`),
);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
"/cases/sc-plus",
"/cases/as-servicos",
]);
// pôster do primeiro case numa moldura no topo, no lugar do fundo verde
// pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde, e não a SEV
const hero = container.querySelector("section");
const heroFrame = hero?.querySelector(".ds-bframe");
expect(heroFrame).not.toBeNull();
expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt",
"Captura de tela do site SEV Moda Íntima",
"Captura de tela do site Lavação 047",
);
expect(hero?.querySelector("a[href='/cases/sev-modaintima']")).not.toBeNull();
expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull();
expect(container.textContent).not.toMatch(/SEV Moda Íntima/);
// 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");
expect(caption).toHaveTextContent(siteService.caseRelevance["lavacao-047"]);
expect(
within(caption as HTMLElement).getByRole("link", {
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
expect(
container.querySelectorAll(
"img[alt='Captura de tela do site Lavação 047']",
),
).toHaveLength(1);
expect(
container.querySelectorAll(
"img[alt='Captura de tela do site SC Plus Aquicultura']",
),
).toHaveLength(1);
});
it("shows no case, no frame and no 'similar projects' button on the diagnosis service, only the Apolo note", async () => {
it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => {
for (const [slug, offer] of [
["criacao-de-sites", serviceOffers[0]],
["engenharia-de-software", serviceOffers[1]],
] as const) {
const { unmount } = await renderService(slug);
expect(
screen.getByRole("heading", { level: 1, name: offer.name }),
).toBeVisible();
const metadata = await generateMetadata({
params: Promise.resolve({ slug }),
});
expect(metadata.title).toBe(getServicePage(slug)?.metaTitle);
expect(metadata.title).not.toBe(offer.name);
unmount();
}
});
it("covers the systems service with the SEV on the phone, in a phone frame, once", async () => {
const { container } = await renderService("engenharia-de-software");
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(hero?.querySelector(".ds-phone")).not.toBeNull();
expect(hero?.querySelector(".ds-bframe")).toBeNull();
expect(
hero
?.querySelector("a[href='/cases/sev-modaintima']")
?.querySelector(".ds-phone-screen"),
).not.toBeNull();
// a SEV não vira imagem de navegador nem ganha cartão: a legenda do celular a cita, com a frase de relação e "Ver case"
expect(container.querySelector("img[alt*='SEV']")).toBeNull();
const caption = hero?.querySelector("figcaption");
expect(caption).toHaveTextContent("SEV Moda Íntima");
expect(caption).toHaveTextContent(
getServicePage("engenharia-de-software")?.caseRelevance["sev-modaintima"] ??
"",
);
expect(
within(caption as HTMLElement).getByRole("link", {
name: "Ver case SEV Moda Íntima",
}),
).toHaveAttribute("href", "/cases/sev-modaintima");
// só o cartão da Lavação 047 traz capa de navegador
expect(
Array.from(container.querySelectorAll(".ds-bframe img")).map((image) =>
image.getAttribute("alt"),
),
).toEqual(["Captura de tela do site Lavação 047"]);
const links = caseCardLinks(container).map((link) =>
link.getAttribute("href"),
);
expect(links).toEqual(["/cases/lavacao-047"]);
});
it("covers the Next.js service with SC Plus, not the SEV", async () => {
const { container } = await renderService("consultoria-nextjs");
const heroFrame = container.querySelector("section .ds-bframe");
expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt",
"Captura de tela do site SC Plus Aquicultura",
);
expect(container.textContent).not.toMatch(/SEV Moda Íntima/);
});
it("shows no case, no frame and no 'similar projects' button on the diagnosis service, only the Apolo note and a flow diagram", async () => {
const { container } = await renderService("arquitetura-de-software");
// nenhum case de cliente é um diagnóstico: só a experiência na Apolo, em texto
expect(
screen.getByRole("heading", { name: "Onde já apliquei isso" }),
screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
).toBeNull();
expect(screen.getByText(/Apolo Energia/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(screen.queryByRole("link", { name: /^Ver case / })).toBeNull();
expect(
screen.queryByRole("link", { name: "Ver projetos parecidos" }),
).toBeNull();
expect(screen.queryByText(/SEV Moda Íntima/)).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
// no lugar da metade vazia do topo: o esquema de fluxo, como imagem com texto alternativo
const diagram = screen.getByRole("img", { name: /interface chama a API/ });
expect(diagram.closest("section")).toBe(container.querySelector("section"));
});
it("lists the Windows app as a 'Projeto próprio' on the desktop service, saying it is made in Python", async () => {
it("lists the Windows app as a 'Projeto próprio' on the desktop service, as a personal Windows app project", async () => {
const { container } = await renderService("tauri-rust");
expect(
screen.getByRole("link", { name: "Ler case: MinGW GCC Installer" }),
screen.getByRole("link", { name: "Ver case MinGW GCC Installer" }),
).toHaveAttribute("href", "/cases/gcc-installer");
expect(
screen.getByText("MinGW GCC Installer · Projeto próprio"),
).toBeVisible();
expect(screen.getByText(/em Python e não em Tauri/)).toBeVisible();
expect(
screen.getByText(/projeto próprio de aplicativo para Windows/),
).toBeVisible();
expect(container.textContent).not.toMatch(/em Python e não em Tauri/);
expect(
screen.getByRole("link", { name: "Ver projetos parecidos" }),
).toHaveAttribute("href", "#casos-relacionados");
// sem captura gravada, não há moldura nem imagem
// sem captura gravada, não há moldura nem imagem: o topo mostra o esquema do aplicativo no computador
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(
screen.getByRole("img", {
name: /aplicativo no computador troca informações/,
}),
).toBeVisible();
// só projeto próprio, sem cliente: o título não fala de "onde já apliquei"
expect(
screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible();
});
it("keeps the hero in one lower column when there is no media, and in two columns when there is", async () => {
for (const slug of [
"tauri-rust",
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
it("keeps the hero in two columns with a cover or a diagram, and in one lower column only when it has neither", async () => {
const diagrams = {
"tauri-rust": /aplicativo no computador/,
"integracao-hardware-software-iot": /dispositivo envia as leituras/,
"arquitetura-de-software": /interface chama a API/,
} as const;
for (const [slug, label] of Object.entries(diagrams)) {
const { container, unmount } = await renderService(slug);
const hero = container.querySelector("section");
expect(hero?.className).not.toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(
within(hero as HTMLElement).getByRole("img", { name: label }),
).toBeVisible();
unmount();
}
// sem capa e sem esquema, o topo volta a ser uma coluna só e mais baixa
const bare = getServicePage("arquitetura-de-software");
if (!bare?.diagram) throw new Error("Missing diagram");
const originalDiagram = bare.diagram;
delete bare.diagram;
try {
const { container, unmount } = await renderService(bare.slug);
const hero = container.querySelector("section");
expect(hero?.className).toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(screen.queryByRole("img")).toBeNull();
unmount();
} finally {
bare.diagram = originalDiagram;
}
const withMedia = await renderService("criacao-de-sites");
@@ -411,7 +628,7 @@ it("puts the location in one line, 'Garuva e Joinville (SC)', and spaces the lis
it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => {
const { container } = await renderService("integracao-hardware-software-iot");
const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ });
const caseLinks = caseCardLinks(container);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab",
]);
@@ -433,19 +650,33 @@ it("labels only the laboratory projects as 'Projeto próprio'", async () => {
});
it("says in one sentence, per case, why it matters to the service", async () => {
for (const { slug, relatedCaseIds, caseRelevance } of servicePages) {
for (const {
slug,
relatedCaseIds,
caseRelevance,
heroCase,
} of servicePages) {
if (relatedCaseIds.length === 0) continue;
const { unmount } = await renderService(slug);
const { container, unmount } = await renderService(slug);
for (const id of relatedCaseIds) {
const sentence = caseRelevance[id];
expect(sentence).toBeTruthy();
const card = screen.getByText(sentence).closest("article");
expect(card).toContainElement(
screen.getByRole("link", {
name: `Ler case: ${caseStudies.find((c) => c.id === id)?.title}`,
}),
);
const link = screen.getByRole("link", {
name: `Ver case ${caseStudies.find((c) => c.id === id)?.title}`,
});
if (id === heroCase?.id) {
// o case da capa: a frase está na legenda dela, junto do botão, e não num cartão
const caption = link.closest("figcaption");
expect(caption).not.toBeNull();
expect(caption).toHaveTextContent(sentence);
expect(
container.querySelectorAll(`article a[href="/cases/${id}"]`),
).toHaveLength(0);
} else {
const card = screen.getByText(sentence).closest("article");
expect(card).toContainElement(link);
}
}
unmount();
}
@@ -465,9 +696,9 @@ it("cites the Apolo Energia experience in text on the IoT and architecture pages
expect(paragraph.textContent).toMatch(/Desde 2022/);
expect(paragraph.closest("a")).toBeNull();
expect(paragraph.querySelector("a")).toBeNull();
// dentro do bloco "Onde já apliquei isso", antes dos cards
// dentro do bloco de prova ("Como eu trabalho nisso": sem case de cliente), antes dos cards
const block = screen.getByRole("heading", {
name: "Onde já apliquei isso",
name: "Como eu trabalho nisso",
});
expect(isBefore(block, paragraph)).toBe(true);
expect(
@@ -497,8 +728,8 @@ it("does not mention Apolo Energia on the other service pages", async () => {
}
});
it("puts the FAQ before the next step and ends every service page with a contact button", async () => {
for (const { slug, faq, ctaLabel } of servicePages) {
it("puts the FAQ before the next step and ends every service page with the same contact button", async () => {
for (const { slug, faq } of servicePages) {
const { container, unmount } = await renderService(slug);
const nextStep = screen.getByRole("heading", {
@@ -517,16 +748,22 @@ it("puts the FAQ before the next step and ends every service page with a contact
const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", {
name: `${ctaLabel} no WhatsApp`,
name: buttonLabels.contact,
});
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me",
);
// as perguntas são as linhas do acordeão (o mesmo desenho da home), todas antes do botão
for (const question of faq ?? []) {
expect(
isBefore(screen.getByRole("heading", { name: question.question }), cta),
).toBe(true);
expect(isBefore(screen.getByText(question.question), cta)).toBe(true);
}
// um rótulo só para o contato: sem "no WhatsApp" colado nem texto por serviço
expect(
screen
.getAllByRole("link", { name: buttonLabels.contact })
.every((link) => link.textContent === buttonLabels.contact),
).toBe(true);
expect(container.textContent).not.toMatch(/diretamente/i);
unmount();
}
});
+35 -15
View File
@@ -11,6 +11,7 @@ import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import { Faq } from "@/app/components/Faq";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
@@ -20,12 +21,14 @@ import {
buildServiceJsonLd,
} from "@/lib/seo";
import {
buttonLabels,
caseStudies,
getRelatedCases,
getServicePage,
servicePages,
siteUrl,
} from "@/lib/site-content";
import { ServiceFaq } from "../_components/ServiceFaq";
import { ServiceDiagram } from "../_components/ServiceDiagram";
import { ServiceHeroCover } from "../_components/ServiceHeroCover";
import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
@@ -75,9 +78,16 @@ export default async function ServicePage({ params }: ServicePageProps) {
}
const relatedCases = getRelatedCases(service.relatedCaseIds);
const heroCase = relatedCases.find((caseStudy) =>
getCaseMedia(caseStudy.id, "desktop"),
// capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo
const heroDevice = service.heroCase?.device ?? "desktop";
const heroCase = caseStudies.find(
({ id }) =>
id === service.heroCase?.id && Boolean(getCaseMedia(id, heroDevice)),
);
const diagram = heroCase ? undefined : service.diagram;
const hasHeroSide = Boolean(heroCase || diagram);
// o case da capa é citado pela legenda dela, e não ganha cartão em "Onde já apliquei isso"
const hasCaseCards = relatedCases.some(({ id }) => id !== heroCase?.id);
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
const jsonLd = {
@@ -98,11 +108,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
<section
className={`ds-section ds-section-dark ${pageTop} ${
heroCase ? "" : compactHero
hasHeroSide ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${heroCase ? heroWithMediaGrid : ""}`}
className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<Breadcrumb
@@ -113,7 +123,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
]}
/>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
<h1 className="ds-section-title max-w-[22ch] text-balance text-white">
{service.headline}
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
@@ -125,21 +135,30 @@ export default async function ServicePage({ params }: ServicePageProps) {
placement="service_detail_hero"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
{buttonLabels.contact}
</WhatsAppLink>
{relatedCases.length > 0 ? (
{hasCaseCards ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
Ver projetos parecidos
</a>
) : null}
</div>
<p className="mt-sp-4 text-[15px] leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
preencher um formulário. Garuva e Joinville (SC) · atendimento
remoto em todo o Brasil.
O botão abre uma conversa no WhatsApp, sem formulário. Garuva e
Joinville (SC) · atendimento remoto em todo o Brasil.
</p>
</div>
<ServiceHeroCover caseStudy={heroCase} />
{diagram ? (
<ServiceDiagram kind={diagram} />
) : (
<ServiceHeroCover
caseStudy={heroCase}
device={heroDevice}
relevance={
heroCase ? service.caseRelevance[heroCase.id] : undefined
}
/>
)}
</div>
</section>
@@ -147,6 +166,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
cases={relatedCases}
relevance={service.caseRelevance}
experienceNote={service.experienceNote}
heroCaseId={heroCase?.id}
/>
<section className="ds-section">
@@ -232,7 +252,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<ServiceOffer service={service} />
<ServiceFaq faq={service.faq} />
<Faq items={service.faq} />
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
@@ -252,13 +272,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
placement="service_detail_footer"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
{buttonLabels.contact}
</WhatsAppLink>
<HoverPrefetchLink
href="/servicos"
className={secondaryButtonDark}
>
Ver todos os serviços
{buttonLabels.allServices}
</HoverPrefetchLink>
</div>
</div>
@@ -0,0 +1,70 @@
import { render, screen } from "@testing-library/react";
import type { ServiceDiagramKind } from "@/lib/site-content";
import { ServiceDiagram } from "./ServiceDiagram";
/** Esquema de fluxo do topo dos serviços sem case com captura: SVG em linha, acessível, sem dado real nem logo de cliente. */
const expected: Record<ServiceDiagramKind, string[]> = {
iot: ["Dispositivo ou sensor", "Gateway", "API", "Painel"],
arquitetura: ["Interface", "API", "Serviços", "Banco de dados"],
desktop: ["Aplicativo no computador", "Arquivos", "Sistemas locais"],
};
describe("ServiceDiagram", () => {
for (const kind of Object.keys(expected) as ServiceDiagramKind[]) {
it(`path: ${kind} -> an inline SVG image with the flow in its label and the steps in order`, () => {
const { container } = render(<ServiceDiagram kind={kind} />);
const svg = screen.getByRole("img");
expect(svg.tagName.toLowerCase()).toBe("svg");
expect(svg).toHaveAttribute("aria-label");
expect(svg.getAttribute("aria-label")?.length).toBeGreaterThan(60);
const labels = Array.from(container.querySelectorAll("svg text")).map(
(text) => text.textContent,
);
expect(labels).toEqual(expected[kind]);
// o texto do desenho está no rótulo da imagem: quem lê a tela ouve o fluxo inteiro
for (const label of expected[kind]) {
expect(svg.getAttribute("aria-label")?.toLowerCase()).toContain(
label.toLowerCase().replace("dispositivo ou sensor", "dispositivo"),
);
}
expect(container.querySelectorAll("svg img, svg image")).toHaveLength(0);
});
}
it("path: IoT and architecture -> three arrows between four steps; desktop -> two arrows, both ways", () => {
const iot = render(<ServiceDiagram kind="iot" />);
expect(iot.container.querySelectorAll("svg line")).toHaveLength(3);
// uma ponta por seta
expect(iot.container.querySelectorAll("svg polygon")).toHaveLength(3);
iot.unmount();
const arquitetura = render(<ServiceDiagram kind="arquitetura" />);
expect(arquitetura.container.querySelectorAll("svg line")).toHaveLength(3);
arquitetura.unmount();
const desktop = render(<ServiceDiagram kind="desktop" />);
expect(desktop.container.querySelectorAll("svg line")).toHaveLength(2);
// duas pontas por seta: a informação vai e volta
expect(desktop.container.querySelectorAll("svg polygon")).toHaveLength(4);
});
it("path: colors -> only site tokens (paper and white), never the lime that belongs to contact, never a client logo", () => {
const { container } = render(<ServiceDiagram kind="iot" />);
const html = container.innerHTML;
expect(html).toMatch(/var\(--paper\)/);
expect(html).toMatch(/var\(--white\)/);
expect(html).not.toMatch(/--slash|#ccff00|ccff00/i);
expect(html).not.toMatch(/<image|<img|href=/i);
});
it("path: caption -> says it is an illustration, with no client data", () => {
render(<ServiceDiagram kind="arquitetura" />);
expect(
screen.getByText("Esquema ilustrativo, sem dados de clientes."),
).toBeVisible();
});
});
+163
View File
@@ -0,0 +1,163 @@
import type { ServiceDiagramKind } from "@/lib/site-content";
/**
* Esquema de fluxo no topo do serviço que não tem case com captura gravada, no lugar da coluna vazia ao lado do título.
* SVG em linha, só com as cores do site (papel e branco sobre o fundo escuro, sem o verde-limão, que é do contato),
* sem dado real e sem logo de cliente. Para quem lê a tela, o esquema é uma imagem com o fluxo descrito em `aria-label`.
*/
const WIDTH = 480;
const HEIGHT = 404;
const ARROW_HEAD = 12;
interface DiagramNode {
label: string;
x: number;
y: number;
width: number;
height: number;
}
interface DiagramArrow {
x: number;
from: number;
to: number;
/** Ponta nas duas extremidades: a informação anda nos dois sentidos. */
both?: boolean;
}
interface DiagramShape {
description: string;
nodes: DiagramNode[];
arrows: DiagramArrow[];
}
/** Quatro etapas em coluna, de cima para baixo, ligadas por setas. */
function column(labels: [string, string, string, string]) {
const nodes = labels.map((label, index) => ({
label,
x: 90,
y: 24 + index * 100,
width: 300,
height: 56,
}));
const arrows = nodes.slice(1).map((node, index) => ({
x: WIDTH / 2,
from: nodes[index].y + nodes[index].height,
to: node.y,
}));
return { nodes, arrows };
}
const diagrams: Record<ServiceDiagramKind, DiagramShape> = {
iot: {
description:
"Esquema do caminho dos dados: o dispositivo envia as leituras ao gateway, o gateway repassa à API e a API alimenta o painel.",
...column(["Dispositivo ou sensor", "Gateway", "API", "Painel"]),
},
arquitetura: {
description:
"Esquema das camadas de um sistema: a interface chama a API, a API aciona os serviços e os serviços guardam e consultam o banco de dados.",
...column(["Interface", "API", "Serviços", "Banco de dados"]),
},
desktop: {
description:
"Esquema de um aplicativo desktop: o aplicativo no computador troca informações, nos dois sentidos, com os arquivos e com os sistemas locais.",
nodes: [
{
label: "Aplicativo no computador",
x: 90,
y: 60,
width: 300,
height: 64,
},
{ label: "Arquivos", x: 30, y: 280, width: 190, height: 64 },
{ label: "Sistemas locais", x: 260, y: 280, width: 190, height: 64 },
],
arrows: [
{ x: 125, from: 124, to: 280, both: true },
{ x: 355, from: 124, to: 280, both: true },
],
},
};
function Arrow({ x, from, to, both = false }: DiagramArrow) {
const lineStart = both ? from + ARROW_HEAD : from;
return (
<g>
<line
x1={x}
y1={lineStart}
x2={x}
y2={to - ARROW_HEAD}
stroke="var(--paper)"
strokeOpacity={0.7}
strokeWidth={2}
/>
<polygon
points={`${x - 7},${to - ARROW_HEAD} ${x + 7},${to - ARROW_HEAD} ${x},${to}`}
fill="var(--paper)"
fillOpacity={0.7}
/>
{both ? (
<polygon
points={`${x - 7},${from + ARROW_HEAD} ${x + 7},${from + ARROW_HEAD} ${x},${from}`}
fill="var(--paper)"
fillOpacity={0.7}
/>
) : null}
</g>
);
}
export function ServiceDiagram({ kind }: { kind: ServiceDiagramKind }) {
const { description, nodes, arrows } = diagrams[kind];
return (
<figure className="mx-auto w-full min-w-0 max-w-[520px]">
<svg
role="img"
aria-label={description}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="block h-auto w-full border border-white/15 bg-[#0a0a0a]"
data-diagram={kind}
>
{arrows.map((arrow) => (
<Arrow key={`${arrow.x}-${arrow.from}`} {...arrow} />
))}
{nodes.map(({ label, x, y, width, height }) => (
<g key={label}>
<rect
x={x}
y={y}
width={width}
height={height}
fill="var(--white)"
fillOpacity={0.06}
stroke="var(--paper)"
strokeOpacity={0.35}
strokeWidth={1.5}
/>
<text
x={x + width / 2}
y={y + height / 2}
textAnchor="middle"
dominantBaseline="central"
fill="var(--paper)"
style={{
fontFamily: "var(--font-display)",
fontSize: 20,
fontWeight: 600,
}}
>
{label}
</text>
</g>
))}
</svg>
<figcaption className="mt-sp-3 text-[15px] leading-6 text-white/70">
Esquema ilustrativo, sem dados de clientes.
</figcaption>
</figure>
);
}
-32
View File
@@ -1,32 +0,0 @@
import type { ServicePageContent } from "@/lib/site-content";
/** "Perguntas frequentes" do serviço. Sem perguntas, a seção não aparece. */
export function ServiceFaq({ faq }: { faq: ServicePageContent["faq"] }) {
if (!faq || faq.length === 0) return null;
return (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de cada
projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
);
}
@@ -43,18 +43,76 @@ describe("ServiceHeroCover", () => {
expect(container).toBeEmptyDOMElement();
});
it("path: case with a capture -> a link to the case around a cover that loads first", () => {
it("path: case with a capture -> a cover that loads first and a caption with the case name, the relevance and 'Ver case'", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<ServiceHeroCover caseStudy={sev} />);
const { container } = render(
<ServiceHeroCover
caseStudy={sev}
relevance="Frase sobre a relação com o serviço."
/>,
);
const link = screen.getByRole("link", {
name: new RegExp(`^Ver o case ${sev.title}`),
name: `Ver case ${sev.title}`,
});
expect(link).toHaveAttribute("href", `/cases/${sev.id}`);
const image = screen.getByAltText(`Captura de tela do site ${sev.title}`);
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
expect(image).toHaveAttribute("sizes", coverSizes.hero);
// a imagem também leva ao case, mas fica fora da leitura e da tabulação: o link da legenda já leva ao mesmo lugar
const coverLink = image.closest("a");
expect(coverLink).toHaveAttribute("aria-hidden", "true");
expect(coverLink).toHaveAttribute("tabindex", "-1");
const caption = container.querySelector("figcaption");
expect(caption).toHaveTextContent(sev.title);
expect(caption).toHaveTextContent("Frase sobre a relação com o serviço.");
expect(caption).toContainElement(link);
});
it("path: no relevance sentence -> the caption keeps the name and the button", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<ServiceHeroCover caseStudy={sev} />);
expect(container.querySelector("figcaption")).toHaveTextContent(
new RegExp(`^${sev.title}Ver case`),
);
expect(
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible();
});
it("path: mobile device -> the phone poster in a phone frame, a link to the case, no browser frame", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<ServiceHeroCover caseStudy={sev} device="mobile" />,
);
expect(mockedGetCaseMedia).toHaveBeenCalledWith(sev.id, "mobile");
const link = screen.getByRole("link", {
name: `Ver case ${sev.title}`,
});
expect(link).toHaveAttribute("href", `/cases/${sev.id}`);
const screenElement = container.querySelector(".ds-phone-screen");
expect(screenElement).not.toBeNull();
expect(
decodeURIComponent(
(screenElement as HTMLElement).style.backgroundImage ?? "",
),
).toContain(`/videos/${sev.id}/mobile.webp`);
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
});
it("path: mobile device but no mobile capture -> renders nothing", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
device === "mobile" ? undefined : getShowcaseAssets(id, device),
);
const { container } = render(
<ServiceHeroCover caseStudy={sev} device="mobile" />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: link -> pre-fetches the case only after the pointer reaches it", () => {
+62 -14
View File
@@ -1,28 +1,76 @@
import { CaseCover, coverSizes } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { textLinkDark } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import type { CaseStudyContent } from "@/lib/site-content";
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
import {
buttonLabels,
type CaseStudyContent,
type HeroDevice,
} from "@/lib/site-content";
/** Capa do case de destaque ao lado do título do serviço. Sem case com captura, não mostra nada. */
/**
* Capa do case de destaque ao lado do título do serviço: o site no computador (moldura de navegador) ou no celular
* (moldura de celular), conforme `device`. Sem captura do aparelho pedido, não mostra nada.
* Embaixo, a legenda: o nome do case, por que ele interessa a este serviço (`relevance`) e o botão "Ver case". É esta
* legenda que cita o case na página: ele não ganha um cartão a mais mais abaixo, porque a mesma imagem e o mesmo nome
* não aparecem duas vezes. A imagem também leva ao case, mas fica fora da leitura e da tabulação (o link da legenda
* já leva ao mesmo lugar).
*/
export function ServiceHeroCover({
caseStudy,
device = "desktop",
relevance,
}: {
caseStudy?: CaseStudyContent;
device?: HeroDevice;
/** Por que o case interessa ao serviço, em uma frase. */
relevance?: string;
}) {
if (!caseStudy) return null;
const href = `/cases/${caseStudy.id}`;
const media =
device === "mobile" ? getCaseMedia(caseStudy.id, "mobile") : null;
if (device === "mobile" && !media) return null;
return (
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className="block min-w-0"
<figure
className={`min-w-0 ${device === "mobile" ? "mx-auto w-full max-w-[360px]" : ""}`}
>
<span className="sr-only">Ver o case {caseStudy.title}</span>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.hero}
priority
/>
</HoverPrefetchLink>
<HoverPrefetchLink
href={href}
aria-hidden="true"
tabIndex={-1}
className={
media ? "mx-auto block w-[min(62vw,240px)] min-w-0" : "block min-w-0"
}
>
{media ? (
<PhoneFrame poster={media.poster} />
) : (
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.hero}
priority
/>
)}
</HoverPrefetchLink>
<figcaption className="mt-sp-4 text-[15px] leading-6 text-white/75">
<strong className="font-semibold text-white">{caseStudy.title}</strong>
{relevance ? `: ${relevance}` : null}
<span className="mt-sp-1 block">
<HoverPrefetchLink
href={href}
className={`${textLinkDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
</span>
</figcaption>
</figure>
);
}
@@ -0,0 +1,111 @@
import { render, screen, within } from "@testing-library/react";
import { caseStudies, type ServiceOffer } from "@/lib/site-content";
import { ServiceOfferCards } from "./ServiceOfferCards";
/**
* Testes de caminhos-base de ServiceOfferCards / OfferCard (CC 4): id de case que existe | que não existe (ignorado);
* com cases | sem cases (a lista de links some); com observação | sem observação.
*/
jest.mock("next/navigation", () => ({ usePathname: () => "/servicos" }));
function offer(overrides: Partial<ServiceOffer["proof"]>): ServiceOffer {
return {
id: "site",
name: "Oferta de teste",
summary: "Resumo.",
href: "/servicos/criacao-de-sites",
deliverables: ["Um", "Dois", "Três"],
proof: { label: "Veja funcionando", caseIds: [], ...overrides },
};
}
const card = () => screen.getByRole("article");
describe("ServiceOfferCards", () => {
it("path: cases that exist and a note -> a link per case in the order given, then the note", () => {
render(
<ServiceOfferCards
offers={[
offer({ caseIds: ["sev-modaintima", "lavacao-047"], note: "Nota." }),
]}
/>,
);
const links = within(card()).getAllByRole("link", { name: /^Ver case / });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
]);
expect(within(card()).getByText("Nota.")).toBeVisible();
});
it("path: an id with no case behind it -> skipped, the real cases around it stay", () => {
render(
<ServiceOfferCards
offers={[
offer({ caseIds: ["nao-existe", "lavacao-047", "tambem-nao"] }),
]}
/>,
);
const links = within(card()).getAllByRole("link", { name: /^Ver case / });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/cases/lavacao-047",
]);
});
it("path: only ids with no case -> no list of cases at all, the label and the service link stay", () => {
render(<ServiceOfferCards offers={[offer({ caseIds: ["nao-existe"] })]} />);
expect(
within(card()).queryAllByRole("link", { name: /^Ver case / }),
).toEqual([]);
expect(within(card()).queryAllByRole("listitem")).toHaveLength(3);
expect(within(card()).getByText("Veja funcionando")).toBeVisible();
expect(
within(card()).getByRole("link", { name: /Ver o serviço|Ver serviço/ }),
).toHaveAttribute("href", "/servicos/criacao-de-sites");
});
it("path: no cases and a note (the review offer) -> only the note under the label", () => {
render(
<ServiceOfferCards
offers={[
offer({
caseIds: [],
label: "Veja como funciona",
note: "A página mostra o que você recebe.",
}),
]}
/>,
);
expect(within(card()).getByText("Veja como funciona")).toBeVisible();
expect(
within(card()).getByText("A página mostra o que você recebe."),
).toBeVisible();
expect(
within(card()).queryAllByRole("link", { name: /^Ver case / }),
).toEqual([]);
});
it("path: cases and no note -> no empty paragraph for the note", () => {
const { container } = render(
<ServiceOfferCards offers={[offer({ caseIds: ["lavacao-047"] })]} />,
);
expect(container.querySelectorAll("article p")).toHaveLength(2);
});
it("path: every real case id of the real offers resolves to a case (no silently dropped proof)", () => {
const { serviceOffers } =
jest.requireActual<typeof import("@/lib/site-content")>(
"@/lib/site-content",
);
const known = new Set(caseStudies.map(({ id }) => id));
for (const { proof } of serviceOffers)
for (const id of proof.caseIds) expect(known.has(id)).toBe(true);
});
});
@@ -0,0 +1,71 @@
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import {
buttonLabels,
caseStudies,
type ServiceOffer,
} from "@/lib/site-content";
function OfferCard({ offer }: { offer: ServiceOffer }) {
const proofCases = offer.proof.caseIds.flatMap((id) => {
const caseStudy = caseStudies.find((item) => item.id === id);
return caseStudy ? [caseStudy] : [];
});
return (
<article className="ds-card">
<h3 className="ds-card-title !mt-0 text-balance">{offer.name}</h3>
<p className="ds-copy !flex-grow-0">{offer.summary}</p>
<ul className="ds-list space-y-sp-2">
{offer.deliverables.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="ds-kicker mt-sp-6">{offer.proof.label}</p>
{proofCases.length > 0 ? (
<ul className="mt-sp-2 flex flex-wrap gap-x-sp-4">
{proofCases.map((caseStudy) => (
<li key={caseStudy.id}>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
<span className="sr-only">{buttonLabels.case} </span>
{caseStudy.title}
</HoverPrefetchLink>
</li>
))}
</ul>
) : null}
{offer.proof.note ? (
<p className="ds-copy !mt-sp-2 !flex-grow-0 text-[15px]">
{offer.proof.note}
</p>
) : null}
<div className="mt-sp-6">
<HoverPrefetchLink
href={offer.href}
className={`${secondaryButtonLight} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{buttonLabels.service}
<span className="sr-only"> {offer.name}</span>
</HoverPrefetchLink>
</div>
</article>
);
}
/** As três ofertas, lado a lado no computador: nome, o que a pessoa recebe, onde ver funcionando e o link da página certa. */
export function ServiceOfferCards({
offers,
}: {
offers: readonly ServiceOffer[];
}) {
return (
<div className="mt-sp-8 grid items-stretch gap-sp-6 lg:grid-cols-3">
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</div>
);
}
@@ -31,7 +31,9 @@ function withDesktopCapture() {
const sev = getCase("sev-modaintima"); // cliente, com site no ar
const asServicos = getCase("as-servicos"); // cliente, sem site público
const twins = getCase("twins-lab"); // laboratório, sem site público
/** Título com algum case de cliente; sem cliente (só experiência ou projeto próprio), é "Como eu trabalho nisso". */
const TITLE = "Onde já apliquei isso";
const TITLE_WITHOUT_CLIENT = "Como eu trabalho nisso";
const NOTE = "Texto de experiência de teste.";
beforeEach(() => {
@@ -57,11 +59,12 @@ describe("ServiceRelatedCases", () => {
);
expect(
screen.getByRole("heading", { level: 2, name: TITLE }),
screen.getByRole("heading", { level: 2, name: TITLE_WITHOUT_CLIENT }),
).toBeVisible();
expect(screen.queryByRole("heading", { name: TITLE })).toBeNull();
expect(screen.getByText(NOTE)).toBeVisible();
expect(container.querySelectorAll("article")).toHaveLength(0);
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(screen.queryByRole("link", { name: /^Ver case/ })).toBeNull();
});
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
@@ -81,14 +84,15 @@ describe("ServiceRelatedCases", () => {
if (!card) throw new Error("Missing case card");
expect(within(card).getByText(sev.headline)).toBeVisible();
// case de cliente: o kicker é só o nome
expect(within(card).getByText(sev.title)).toBeVisible();
const kicker = card.querySelector(".ds-kicker");
expect(kicker).toHaveTextContent(new RegExp(`^${sev.title}$`));
expect(within(card).queryByText(/Projeto próprio/)).toBeNull();
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
expect(
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }),
within(card).getByRole("link", { name: `Ver case ${sev.title}` }),
).toHaveAttribute("href", `/cases/${sev.id}`);
expect(
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
within(card).getByRole("link", { name: `Abrir o site ${sev.title}` }),
).toHaveAttribute("href", sev.demoUrl);
});
@@ -107,7 +111,7 @@ describe("ServiceRelatedCases", () => {
expect(note.querySelector("a")).toBeNull();
expect(
note.compareDocumentPosition(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
@@ -138,7 +142,7 @@ describe("ServiceRelatedCases", () => {
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
expect(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible();
});
@@ -152,6 +156,42 @@ describe("ServiceRelatedCases", () => {
);
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
// só projeto próprio, sem cliente: o título não promete trabalho aplicado para alguém
expect(
screen.getByRole("heading", { level: 2, name: TITLE_WITHOUT_CLIENT }),
).toBeVisible();
});
it("path: case whose cover already opens the page -> it gets no card at all (the cover caption cites it), the others keep theirs", () => {
withDesktopCapture();
const lavacao = getCase("lavacao-047");
const { container } = render(
<ServiceRelatedCases
cases={[lavacao, sev]}
relevance={{}}
heroCaseId={lavacao.id}
/>,
);
expect(container.querySelectorAll("article")).toHaveLength(1);
expect(
container.querySelector(`a[href="/cases/${lavacao.id}"]`),
).toBeNull();
expect(container.textContent).not.toContain(lavacao.title);
expect(
container.querySelector(`a[aria-hidden="true"][href="/cases/${sev.id}"]`),
).not.toBeNull();
expect(
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible();
});
it("path: the cover case is the only one -> no cases to list and no experience note -> renders nothing", () => {
const { container } = render(
<ServiceRelatedCases cases={[sev]} relevance={{}} heroCaseId={sev.id} />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
@@ -172,7 +212,7 @@ describe("ServiceRelatedCases", () => {
);
expect(
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
screen.getByRole("link", { name: `Ver case ${asServicos.title}` }),
).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
@@ -18,7 +18,7 @@ it("shows only the experience note, without the 'open the case' sentence, when t
);
expect(
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible();
expect(screen.getByText(note)).toBeVisible();
expect(screen.queryByText(/Abra o case/)).toBeNull();
@@ -37,7 +37,7 @@ it("invites to open the case and lists each case when there are some", () => {
expect(screen.getByText(/Abra o case/)).toBeVisible();
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
expect(
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }),
screen.getByRole("link", { name: `Ver case ${caseStudy.title}` }),
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
});
@@ -1,7 +1,11 @@
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
import {
buttonLabels,
type CaseStudyContent,
isClientCase,
} from "@/lib/site-content";
function ServiceCaseCard({
caseStudy,
@@ -31,7 +35,8 @@ function ServiceCaseCard({
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ler case: {caseStudy.title}
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
@@ -40,7 +45,8 @@ function ServiceCaseCard({
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
@@ -49,22 +55,30 @@ function ServiceCaseCard({
}
/**
* "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma frase sobre o que ele tem a ver com ele,
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase
* que manda abrir o case; sem case nem experiência, não mostra nada.
* Prova do serviço. Com algum case de cliente, é "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma
* frase sobre o que ele tem a ver com ele, e a experiência profissional citada em texto (sem link). Sem case de cliente
* (só projeto próprio, só experiência ou nada), o título é "Como eu trabalho nisso", porque não há cliente para citar.
* Sem case, fica só a nota de experiência, sem a frase que manda abrir o case; sem case nem experiência, não mostra nada.
* O case cuja capa abre a página (`heroCaseId`) não ganha cartão aqui: a legenda da capa já o cita, com a mesma frase de
* relação, e a imagem e o nome dele não aparecem duas vezes na mesma página.
*/
export function ServiceRelatedCases({
cases,
cases: allCases,
relevance,
experienceNote,
heroCaseId,
}: {
cases: readonly CaseStudyContent[];
/** Por que cada case interessa a este serviço (chave: id do case). */
relevance: Readonly<Record<string, string>>;
experienceNote?: string;
/** Case cuja capa já está no topo da página, com legenda: fica de fora dos cartões. */
heroCaseId?: string;
}) {
const cases = allCases.filter(({ id }) => id !== heroCaseId);
const hasCases = cases.length > 0;
if (!hasCases && !experienceNote) return null;
const hasClientCase = cases.some(isClientCase);
return (
<section
@@ -75,9 +89,13 @@ export function ServiceRelatedCases({
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Trabalhos parecidos</p>
<p className="ds-eyebrow">
{hasClientCase ? "Trabalhos parecidos" : "Experiência"}
</p>
<h2 id="casos-relacionados-titulo" className="ds-section-title">
Onde já apliquei isso
{hasClientCase
? "Onde já apliquei isso"
: "Como eu trabalho nisso"}
</h2>
{hasCases ? (
<p className="ds-section-description">
+21 -2
View File
@@ -34,8 +34,27 @@ describe("ServicesIndexPage: topo sem captura gravada", () => {
name: "Sites, sistemas e aplicativos sob medida.",
}),
).toBeVisible();
// o botão do topo leva à lista de cases (não há mais seção de projetos nesta página)
expect(screen.getByRole("link", { name: "Ver os cases" })).toHaveAttribute(
"href",
"/cases",
);
const contactLinks = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contactLinks).toHaveLength(2);
for (const link of contactLinks) {
expect(link).toHaveAttribute(
"href",
expect.stringContaining("https://wa.me/"),
);
}
// as três ofertas seguem no topo da página, mesmo sem nenhuma captura
expect(
screen.getByRole("link", { name: "Ver projetos realizados" }),
).toHaveAttribute("href", "#projetos");
screen.getByRole("heading", {
level: 2,
name: "Três jeitos de começar.",
}),
).toBeVisible();
});
});
+74 -171
View File
@@ -1,6 +1,4 @@
import type { Metadata } from "next";
import { coverSizes } from "@/app/cases/_components/CaseCover";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import {
compactHero,
heroWithMediaGrid,
@@ -14,47 +12,48 @@ import {
pageTop,
primaryButton,
secondaryButtonDark,
textLink,
} from "@/app/components/button-styles";
import { Faq } from "@/app/components/Faq";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content";
import {
buttonLabels,
clientCasesForListing,
serviceHubLinks,
serviceOffers,
siteUrl,
} from "@/lib/site-content";
import { SERVICES_SUBJECT } from "@/lib/whatsapp";
import { ServiceOfferCards } from "./_components/ServiceOfferCards";
const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Sites, sistemas e aplicativos sob medida";
const pageDescription =
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Garuva e Joinville (SC), remoto em todo o Brasil.";
const businessIntents = [
/** Páginas que as três ofertas já abrem; os demais serviços ficam na lista "Outros serviços". */
const offerHrefs = new Set(serviceOffers.map(({ href }) => href));
const otherServices = serviceHubLinks.filter(
({ href }) => !offerHrefs.has(href),
);
const hubFaq = [
{
title: "Quero um site para meu negócio",
description:
"Apresente seus serviços, produtos e diferenciais em um endereço próprio, com leitura no celular e um caminho claro para o cliente entrar em contato.",
href: "/servicos/criacao-de-sites",
linkLabel: "Conhecer a criação de sites",
service: "Sites profissionais",
ctaLabel: "Conversar sobre meu site",
question: "Quanto custa e quanto tempo leva?",
answer:
"Depende das funcionalidades, conteúdo e integrações. O valor e o prazo são definidos depois de entender os requisitos, antes de iniciar o desenvolvimento.",
},
{
title: "Preciso organizar minha operação",
description:
"Um sistema web para acompanhar pedidos, atendimentos ou informações hoje espalhadas em planilhas. Também posso conectar ferramentas e melhorar o software que você já usa.",
href: "/servicos/engenharia-de-software",
linkLabel: "Conhecer o desenvolvimento de sistemas",
service: "Sistemas web",
ctaLabel: "Conversar sobre meu sistema",
question: "Preciso estar em Joinville ou em Garuva?",
answer:
"Não. O atendimento é remoto, com conversas, demonstrações e revisões on-line para projetos em todo o Brasil.",
},
{
title: "Quero desenvolver um aplicativo",
description:
"Leve um fluxo de trabalho para o celular ou computador. Avaliamos quem vai usar, os recursos necessários e a plataforma antes de definir o desenvolvimento e a distribuição.",
href: "/servicos/engenharia-de-software",
linkLabel: "Entender o escopo de um app mobile",
service: "Aplicativos mobile e desktop",
ctaLabel: "Conversar sobre meu aplicativo",
question: "E se eu já tiver um site ou sistema?",
answer:
"Avalio a base atual para propor melhorias, correções ou integrações. Recomeçar do zero não é uma condição para trabalhar comigo.",
},
];
@@ -70,8 +69,9 @@ export const metadata: Metadata = {
};
export default function ServicesIndexPage() {
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia
const posters = getMosaicPosters(clientCases.map(({ id }) => id));
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (ela vai por último)
// e nenhum cliente se repete mais abaixo, onde as ofertas citam os cases só por link
const posters = getMosaicPosters(clientCasesForListing.map(({ id }) => id));
const hasMosaic = posters.length > 0;
const jsonLd = {
"@context": "https://schema.org",
@@ -132,18 +132,17 @@ export default function ServicesIndexPage() {
service={SERVICES_SUBJECT}
placement="service_hub_hero"
>
Falar com Evandro no WhatsApp
{buttonLabels.contact}
</WhatsAppLink>
<a
href="#projetos"
<HoverPrefetchLink
href="/cases"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver projetos realizados
</a>
{buttonLabels.allCases}
</HoverPrefetchLink>
</div>
<p className="ds-copy max-w-[62ch]">
Sou Evandro, desenvolvedor de software, e a conversa é direta
comigo.
Sou Evandro, desenvolvedor de software, e a conversa é comigo.
</p>
<p className="mt-sp-2 text-[15px] leading-6 text-white/70">
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil
@@ -153,155 +152,35 @@ export default function ServicesIndexPage() {
</div>
</section>
<section className="ds-section" aria-labelledby="necessidade-title">
<div className="ds-section-shell grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-sp-16">
<div>
<h2 id="necessidade-title" className="ds-section-title">
O que você precisa fazer?
</h2>
<p className="ds-copy">
Não precisa chegar com um projeto técnico pronto. Começamos pela
sua rotina e pelo que precisa mudar.
</p>
</div>
<div className="divide-y divide-[var(--border)]">
{businessIntents.map((intent) => (
<article
key={intent.service}
className="py-sp-6 first:pt-0 last:pb-0"
>
<h3 className="ds-card-title">{intent.title}</h3>
<p className="ds-copy">{intent.description}</p>
<div className="mt-sp-4 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2">
<WhatsAppLink
className={primaryButton}
service={intent.service}
placement="service_hub_intent"
>
{intent.ctaLabel}
</WhatsAppLink>
<HoverPrefetchLink
href={intent.href}
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{intent.linkLabel}
</HoverPrefetchLink>
</div>
</article>
))}
</div>
</div>
</section>
<section
id="projetos"
className="ds-section"
aria-labelledby="projetos-title"
>
<section className="ds-section" aria-labelledby="ofertas-title">
<div className="ds-section-shell">
<h2 id="projetos-title" className="ds-section-title">
Veja o trabalho antes de conversar.
<h2
id="ofertas-title"
className="ds-section-title max-w-[26ch] text-balance"
>
Três jeitos de começar.
</h2>
<p className="ds-copy max-w-[64ch]">
Projetos feitos para empresas. Abra o case para ver o que foi feito
e como o site ficou.
Não precisa chegar com um projeto técnico pronto. Escolha o que mais
se parece com o seu caso; começamos pela sua rotina e pelo que
precisa mudar.
</p>
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2">
{clientCases.map((caseStudy) => (
<article
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.column}
/>
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} mt-sp-4 focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver o case {caseStudy.title}
</HoverPrefetchLink>
</article>
))}
</div>
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell grid gap-sp-10 lg:grid-cols-2 lg:gap-sp-16">
<div>
<h2 className="ds-section-title">
Como saímos da conversa para a entrega.
</h2>
<ol className="ds-list space-y-sp-4 list-decimal">
<li>
<strong>Entender o problema.</strong> Conversamos sobre o
negócio, quem vai usar e o que já existe.
</li>
<li>
<strong>Combinar o escopo.</strong> Definimos prioridades,
plataformas, responsabilidades, orçamento e prazo na proposta.
</li>
<li>
<strong>Construir e revisar.</strong> Você acompanha as etapas.
Validamos a entrega e combinamos publicação, suporte e evolução.
</li>
</ol>
</div>
<div>
<h2 className="ds-card-title">Antes de contratar</h2>
<dl className="mt-sp-5 space-y-sp-6 text-[15px]">
<div>
<dt className="font-semibold">
Quanto custa e quanto tempo leva?
</dt>
<dd className="ds-copy !mt-sp-2">
Depende das funcionalidades, conteúdo e integrações. O valor e
o prazo são definidos depois de entender os requisitos, antes
de iniciar o desenvolvimento.
</dd>
</div>
<div>
<dt className="font-semibold">
Preciso estar em Joinville ou em Garuva?
</dt>
<dd className="ds-copy !mt-sp-2">
Não. O atendimento é remoto, com conversas, demonstrações e
revisões on-line para projetos em todo o Brasil.
</dd>
</div>
<div>
<dt className="font-semibold">
E se eu já tiver um site ou sistema?
</dt>
<dd className="ds-copy !mt-sp-2">
Avalio a base atual para propor melhorias, correções ou
integrações. Recomeçar do zero não é uma condição para
trabalhar comigo.
</dd>
</div>
</dl>
</div>
<ServiceOfferCards offers={serviceOffers} />
</div>
</section>
<section className="ds-section" aria-labelledby="servicos-title">
<div className="ds-section-shell">
<h2 id="servicos-title" className="ds-section-title">
Quer aprofundar o escopo técnico?
Outros serviços
</h2>
<p className="ds-copy max-w-[64ch]">
Consulte as áreas de atuação, incluindo desktop, integrações,
consultoria e IoT. Cada página explica o que posso fazer por você.
Consultoria em Next.js, aplicativos desktop, integração de
equipamentos e diagnóstico de arquitetura. Cada página explica o que
posso fazer por você.
</p>
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service) => (
{otherServices.map((service) => (
<li
key={service.href}
className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-sp-8"
@@ -319,6 +198,30 @@ export default function ServicesIndexPage() {
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<h2 className="ds-section-title max-w-[26ch] text-balance">
Como saímos da conversa para a entrega.
</h2>
<ol className="ds-list max-w-[68ch] list-decimal space-y-sp-4">
<li>
<strong>Entender o problema.</strong> Conversamos sobre o negócio,
quem vai usar e o que já existe.
</li>
<li>
<strong>Combinar o escopo.</strong> Definimos prioridades,
plataformas, responsabilidades, orçamento e prazo na proposta.
</li>
<li>
<strong>Construir e revisar.</strong> Você acompanha as etapas.
Validamos a entrega e combinamos publicação, suporte e evolução.
</li>
</ol>
</div>
</section>
<Faq items={hubFaq} title="Antes de contratar" />
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<h2 className="ds-section-title max-w-[24ch]">
@@ -334,7 +237,7 @@ export default function ServicesIndexPage() {
service={SERVICES_SUBJECT}
placement="service_hub_footer"
>
Conversar com Evandro no WhatsApp
{buttonLabels.contact}
</WhatsAppLink>
</div>
</section>
+43 -4
View File
@@ -1,4 +1,9 @@
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import {
caseStudies,
pageUpdatedAt,
servicePages,
siteUrl,
} from "@/lib/site-content";
import sitemap from "./sitemap";
/** O mapa do site lista a home, as páginas fixas, cada serviço e cada case, sem repetir endereço. */
@@ -26,8 +31,42 @@ describe("sitemap", () => {
expect(urls.some((url) => /not-found|nada/.test(url))).toBe(false);
});
it("gives every entry the same fixed date, so the sitemap is equal on every build", () => {
const dates = sitemap().map((entry) => String(entry.lastModified));
expect(new Set(dates).size).toBe(1);
it("gives every route the modification date of its own content, equal on every build", () => {
const day = (value: Date | string | undefined) =>
new Date(value ?? "").toISOString().slice(0, 10);
const byUrl = new Map(
sitemap().map((entry) => [entry.url, day(entry.lastModified)]),
);
expect(byUrl.get(siteUrl)).toBe(pageUpdatedAt.home);
expect(byUrl.get(`${siteUrl}/curriculo`)).toBe(pageUpdatedAt.curriculo);
expect(byUrl.get(`${siteUrl}/privacidade`)).toBe(pageUpdatedAt.privacidade);
expect(byUrl.get(`${siteUrl}/servicos`)).toBe(pageUpdatedAt.servicos);
expect(byUrl.get(`${siteUrl}/cases`)).toBe(pageUpdatedAt.cases);
expect(byUrl.get(`${siteUrl}/ia`)).toBe(pageUpdatedAt.ia);
for (const { slug, updatedAt } of servicePages) {
expect(byUrl.get(`${siteUrl}/servicos/${slug}`)).toBe(updatedAt);
}
for (const { id, updatedAt } of caseStudies) {
expect(byUrl.get(`${siteUrl}/cases/${id}`)).toBe(updatedAt);
}
// não é uma data única copiada para todas as rotas, e não vem do relógio: duas chamadas dão o mesmo resultado
expect(new Set(byUrl.values()).size).toBeGreaterThan(1);
expect(sitemap()).toEqual(sitemap());
});
it("keeps every content date a real calendar day (AAAA-MM-DD) that is not in the future", () => {
const days = [
...Object.values(pageUpdatedAt),
...servicePages.map(({ updatedAt }) => updatedAt),
...caseStudies.map(({ updatedAt }) => updatedAt),
];
for (const value of days) {
expect(value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const parsed = new Date(`${value}T00:00:00Z`);
expect(parsed.toISOString().slice(0, 10)).toBe(value);
expect(parsed.getTime()).toBeLessThanOrEqual(Date.now());
}
});
});
+34 -12
View File
@@ -1,22 +1,44 @@
import type { MetadataRoute } from "next";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import {
caseStudies,
pageUpdatedAt,
servicePages,
siteUrl,
} from "@/lib/site-content";
/** Data fixa da última mudança de conteúdo; o sitemap precisa ser igual a cada build. Atualize quando as páginas mudarem. */
const lastModified = new Date("2026-10-14T00:00:00Z");
/**
* `lastmod` de cada rota: a data de mudança do conteúdo dela (`updatedAt` do serviço ou do case, `pageUpdatedAt` nas
* páginas fixas), sempre a mesma a cada build. Não vem do relógio nem do git, que mudariam sem o texto mudar.
* Atualize a data quando o texto da página mudar.
*/
function dateOf(day: string) {
return new Date(`${day}T00:00:00Z`);
}
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: siteUrl },
{ url: `${siteUrl}/curriculo` },
{ url: `${siteUrl}/privacidade` },
{ url: `${siteUrl}/servicos` },
{ url: `${siteUrl}/cases` },
{ url: `${siteUrl}/ia` },
...servicePages.map(({ slug }) => ({
{ url: siteUrl, lastModified: dateOf(pageUpdatedAt.home) },
{
url: `${siteUrl}/curriculo`,
lastModified: dateOf(pageUpdatedAt.curriculo),
},
{
url: `${siteUrl}/privacidade`,
lastModified: dateOf(pageUpdatedAt.privacidade),
},
{
url: `${siteUrl}/servicos`,
lastModified: dateOf(pageUpdatedAt.servicos),
},
{ url: `${siteUrl}/cases`, lastModified: dateOf(pageUpdatedAt.cases) },
{ url: `${siteUrl}/ia`, lastModified: dateOf(pageUpdatedAt.ia) },
...servicePages.map(({ slug, updatedAt }) => ({
url: `${siteUrl}/servicos/${slug}`,
lastModified: dateOf(updatedAt),
})),
...caseStudies.map(({ id }) => ({
...caseStudies.map(({ id, updatedAt }) => ({
url: `${siteUrl}/cases/${id}`,
lastModified: dateOf(updatedAt),
})),
].map((entry) => ({ ...entry, lastModified }));
];
}