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 (`[]`). * 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) { function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id); 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. * 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", () => ({ jest.mock("next/navigation", () => ({
notFound: jest.fn(() => { notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND"); throw new Error("NEXT_NOT_FOUND");
}), }),
usePathname: () => "/cases",
})); }));
jest.mock("../_components/case-media", () => { 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.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull(); expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull(); expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
expect( // o NutriScan tem "Como ficou": o botão de contato vem depois dele e de novo no fecho, com o mesmo rótulo
screen.getByRole("link", { const contacts = screen.getAllByRole("link", {
name: "Tem um problema parecido? Vamos conversar.", name: "Falar sobre meu projeto",
}), });
).toBeVisible(); expect(contacts).toHaveLength(2);
for (const contact of contacts) expect(contact).toBeVisible();
expect( expect(
screen.getByRole("link", { name: "Ver outros cases" }), screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases"); ).toHaveAttribute("href", "/cases");
+132 -44
View File
@@ -1,9 +1,20 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; 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 CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } 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 = [ const clientCaseIds = [
"sev-modaintima", "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. * Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma * ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* frase nos dois, a do cabeçalho: "um projeto parecido com o {título}". * `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 const hrefs = screen
.getAllByRole("link") .getAllByRole("link")
.map((link) => link.getAttribute("href") ?? ""); .map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact"); expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false); expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const isClient = clientCaseIds.includes(caseStudy.id); const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
const cta = screen.getByRole("link", { expect(ctas).toHaveLength(expectedButtons);
name: isClient for (const cta of ctas) {
? "Conversar sobre um projeto parecido" const destination = new URL(cta.getAttribute("href") ?? "");
: "Tem um problema parecido? Vamos conversar.", expect(destination.origin).toBe("https://wa.me");
}); expect(destination.searchParams.get("text")).toContain(
const destination = new URL(cta.getAttribute("href") ?? ""); similarProjectSubject(caseStudy.title),
expect(destination.origin).toBe("https://wa.me"); );
expect(destination.searchParams.get("text")).toContain( expect(cta).toHaveAttribute("target", "_blank");
`um projeto parecido com o ${caseStudy.title}`, 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).toHaveAttribute("target", "_blank"); expect(cta.className).toContain("bg-[var(--slash)]");
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 () => { 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 problema",
"O que foi feito", "O que foi feito",
"Como ficou", "Como ficou",
"O que o cliente passou a poder fazer",
"Quer um projeto parecido com este?", "Quer um projeto parecido com este?",
]; ];
const sectionHeadings = sectionNames.map((name) => 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}` }), screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl); ).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 // detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
const details = container.querySelector("details"); const details = container.querySelector("details");
@@ -260,7 +293,8 @@ it("renders the two new client cases from the portfolio data", async () => {
expect( expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl); ).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(); unmount();
} }
}); });
@@ -289,24 +323,71 @@ it("labels a laboratory case, skips the media block and shows its service as a c
expectContactAndNoDeadLinks(caseStudy); 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) { for (const id of labCaseIds) {
const { unmount } = await renderCase(id); const { unmount } = await renderCase(id);
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull(); expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
expect( expect(
screen.queryByRole("link", { screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido", name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
}), }),
).toBeNull(); ).toBeNull();
expect( expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }), screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible(); ).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(); 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 () => { it("shows the author's role and no site button for a client project without a public site", async () => {
const caseStudy = getCase("as-servicos"); const caseStudy = getCase("as-servicos");
expect(caseStudy.demoUrl).toBeNull(); 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 () => { 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 // lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
const sev = getCase("sev-modaintima"); const sev = getCase("lavacao-047");
const all = getRelatedServices(sev.id); const all = getRelatedServices(sev.id);
expect(all.length).toBeGreaterThan(2); expect(all.length).toBeGreaterThan(2);
const { unmount } = await renderCase(sev.id); 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( expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }), screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull(); ).toBeNull();
const contact = screen.getByRole("link", { // o botão do fecho (o último) vem antes dos cartões
name: "Conversar sobre um projeto parecido", const contacts = screen.getAllByRole("link", { name: buttonLabels.contact });
}); expect(isBefore(contacts[contacts.length - 1], cards[0])).toBe(true);
expect(isBefore(contact, cards[0])).toBe(true);
unmount(); 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 [ for (const [id, expected] of [
["sc-plus", 2], ["sc-plus", 2],
["as-servicos", 1], ["as-servicos", 1],
["sev-modaintima", 1],
] as const) { ] as const) {
const { unmount: unmountCase } = await renderCase(id); const { unmount: unmountCase } = await renderCase(id);
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ }); 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) .slice(0, 2)
.map(({ slug }) => `/servicos/${slug}`), .map(({ slug }) => `/servicos/${slug}`),
); );
expectContactAndNoDeadLinks(getCase(id)); expectContactAndNoDeadLinks(getCase(id), 2);
unmountCase(); 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", () => { it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
const { container } = render(<CasesPage />); const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 }); const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent( expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
/foi feito para empresas/i, // 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); expect(container.textContent).not.toMatch(/Selected work/i);
const clientHeading = screen.getByRole("heading", { 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(isBefore(clientHeading, labHeading)).toBe(true);
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible(); 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 // cases de cliente, com a SEV por último: capa (pôster) em moldura de navegador e botão "Ver case" em contorno
const clientLinks = clientCaseIds.map((id) => { 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 const card = container
.querySelector(`a[href="/cases/${id}"]`) .querySelector(`a[href="/cases/${id}"]`)
?.closest("article"); ?.closest("article");
@@ -583,14 +667,18 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
"alt", "alt",
`Captura de tela do site ${getCase(id).title}`, `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( 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 // 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( expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`), 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 row = link.closest("li");
const caseStudy = getCase(labCaseIds[index]); const caseStudy = getCase(labCaseIds[index]);
expect(isBefore(labHeading, link)).toBe(true); expect(isBefore(labHeading, link)).toBe(true);
expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`); expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
expect( expect(
within(row as HTMLElement).getByRole("heading", { within(row as HTMLElement).getByRole("heading", {
level: 3, 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).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false); expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
expect(hrefs).toContain("/servicos"); 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"); expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", { const visibleBreadcrumb = screen.getByRole("navigation", {
+3
View File
@@ -7,6 +7,7 @@ import {
buildPageSocialMetadata, buildPageSocialMetadata,
} from "@/lib/seo"; } from "@/lib/seo";
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content"; import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
import { CaseCapabilitiesSection } from "../_components/CaseCapabilitiesSection";
import { CaseContactSection } from "../_components/CaseContactSection"; import { CaseContactSection } from "../_components/CaseContactSection";
import { CaseHero } from "../_components/CaseHero"; import { CaseHero } from "../_components/CaseHero";
import { CaseMediaSection } from "../_components/CaseMediaSection"; import { CaseMediaSection } from "../_components/CaseMediaSection";
@@ -136,6 +137,8 @@ export default async function CasePage({ params }: CasePageProps) {
<CaseMediaSection caseStudy={caseStudy} /> <CaseMediaSection caseStudy={caseStudy} />
<CaseCapabilitiesSection caseStudy={caseStudy} isClient={isClient} />
<CaseResultSection result={caseStudy.result} /> <CaseResultSection result={caseStudy.result} />
<CaseContactSection caseStudy={caseStudy} isClient={isClient} /> <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 { render, screen } from "@testing-library/react";
import { getRelatedServices } from "@/lib/site-content"; import { getRelatedServices } from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import { CaseContactSection } from "./CaseContactSection"; import { CaseContactSection } from "./CaseContactSection";
/** /**
@@ -25,12 +26,11 @@ describe("CaseContactSection", () => {
name: "Quer um projeto parecido com este?", name: "Quer um projeto parecido com este?",
}), }),
).toBeVisible(); ).toBeVisible();
const cta = screen.getByRole("link", { const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
name: "Conversar sobre um projeto parecido",
});
expect(cta).toHaveAttribute("target", "_blank"); 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( 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(getRelatedServices(sev.id).length).toBeGreaterThan(0);
expect( expect(
@@ -47,17 +47,16 @@ describe("CaseContactSection", () => {
expect( expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }), screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible(); ).toBeVisible();
const cta = screen.getByRole("link", { // o rótulo do botão é o mesmo do case de cliente: uma lista só
name: "Tem um problema parecido? Vamos conversar.", 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
// a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain( 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(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
expect( expect(
screen.queryByRole("link", { screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido", name: /Tem um problema parecido|Conversar sobre um projeto parecido/,
}), }),
).toBeNull(); ).toBeNull();
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua // 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"; } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink"; 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 { similarProjectSubject } from "@/lib/whatsapp";
import { CaseRelatedServices } from "./CaseRelatedServices"; 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. * 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 assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`. * 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 = { const contactCopy = {
client: { client: { title: "Quer um projeto parecido com este?" },
title: "Quer um projeto parecido com este?", lab: { title: "Fale sobre o seu caso" },
action: "Conversar sobre um projeto parecido",
},
lab: {
title: "Fale sobre o seu caso",
action: "Tem um problema parecido? Vamos conversar.",
},
} as const; } as const;
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */ /** 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} {copy.title}
</h2> </h2>
<p className="ds-copy max-w-[60ch]"> <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. formulário.
</p> </p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3"> <div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
@@ -52,7 +47,7 @@ export function CaseContactSection({
placement="case_detail" placement="case_detail"
className={primaryButton} className={primaryButton}
> >
{copy.action} {buttonLabels.contact}
</WhatsAppLink> </WhatsAppLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}> <HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases Ver outros cases
+3 -2
View File
@@ -1,5 +1,5 @@
import { secondaryButtonDark } from "@/app/components/button-styles"; 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 { CaseMedia } from "./CaseMedia";
import { getCaseMedia } from "./case-media"; import { getCaseMedia } from "./case-media";
@@ -45,7 +45,8 @@ export function CaseMediaSection({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Abrir o site {caseStudy.title} {buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a> </a>
</div> </div>
) : null} ) : null}
+13 -19
View File
@@ -1,11 +1,11 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { clientCases } from "@/lib/site-content"; import { clientCasesForListing as clientCases } from "@/lib/site-content";
import CasesPage from "./page"; 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 * Carregamento da lista de cases: sem mosaico no topo, a capa do primeiro cartão (a maior imagem acima da dobra) carrega
* 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. * 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>; type LinkProps = ComponentProps<typeof import("next/link").default>;
@@ -40,26 +40,20 @@ function coverImages(container: HTMLElement) {
} }
describe("CasesPage: carregamento das capas", () => { 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 { container } = render(<CasesPage />);
const mosaic = Array.from( // sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
container.querySelectorAll<HTMLImageElement>("section [aria-hidden] img"), const decorative = Array.from(
container.querySelectorAll<HTMLImageElement>("img"),
).filter((image) => image.getAttribute("alt") === ""); ).filter((image) => image.getAttribute("alt") === "");
const [main, ...side] = mosaic; expect(decorative).toHaveLength(0);
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");
}
const covers = coverImages(container); const [first, ...others] = coverImages(container);
expect(covers.length).toBeGreaterThan(0); expect(first).toHaveAttribute("loading", "eager");
for (const image of covers) { expect(first).toHaveAttribute("fetchpriority", "high");
expect(others.length).toBeGreaterThan(0);
for (const image of others) {
expect(image).toHaveAttribute("loading", "lazy"); expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority"); expect(image).not.toHaveAttribute("fetchpriority");
} }
+73 -38
View File
@@ -1,5 +1,9 @@
import { render, screen, within } from "@testing-library/react"; 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 { getCaseMedia } from "./_components/case-media";
import CasesPage from "./page"; import CasesPage from "./page";
@@ -32,7 +36,7 @@ function clientCard(container: HTMLElement, id: string) {
describe("CasesPage: cases de cliente", () => { describe("CasesPage: cases de cliente", () => {
it("path: client case without a desktop capture -> card keeps text and links but no cover", () => { 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) => mockedGetCaseMedia.mockImplementation((id, device) =>
id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device), id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device),
); );
@@ -42,14 +46,12 @@ describe("CasesPage: cases de cliente", () => {
const bare = clientCard(container, withoutCapture.id); const bare = clientCard(container, withoutCapture.id);
expect(bare.querySelector(".ds-bframe")).toBeNull(); expect(bare.querySelector(".ds-bframe")).toBeNull();
expect(bare.querySelector("img")).toBeNull(); expect(bare.querySelector("img")).toBeNull();
expect( expect(bare.querySelector("a[aria-hidden='true']")).toBeNull();
within(bare).queryByRole("link", {
name: `Ver o case ${withoutCapture.title}`,
}),
).toBeNull();
expect(within(bare).getByText(withoutCapture.headline)).toBeVisible(); expect(within(bare).getByText(withoutCapture.headline)).toBeVisible();
expect( expect(
within(bare).getByRole("link", { name: "Ver o case" }), within(bare).getByRole("link", {
name: `${buttonLabels.case} ${withoutCapture.title}`,
}),
).toHaveAttribute("href", `/cases/${withoutCapture.id}`); ).toHaveAttribute("href", `/cases/${withoutCapture.id}`);
// os outros cases de cliente continuam com a capa // os outros cases de cliente continuam com a capa
@@ -58,55 +60,88 @@ describe("CasesPage: cases de cliente", () => {
"alt", "alt",
`Captura de tela do site ${withCapture.title}`, `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", () => { describe("CasesPage: topo sem mosaico", () => {
it("path: captures found -> two columns with three posters of the other clients, not the first card's", () => { 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(".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,
);
const { container } = render(<CasesPage />); const { container } = render(<CasesPage />);
const hero = container.querySelector("section"); const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toBeNull(); expect(hero?.querySelector("img")).toBeNull();
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(hero?.className).toContain("md:!pb-sp-12"); expect(hero?.className).toContain("md:!pb-sp-12");
const firstCover = clientCard(container, clientCases[0].id).querySelector( // o título e o subtítulo não repetem a mesma frase
".ds-bframe img", 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("loading", "eager");
expect(firstCover).toHaveAttribute("fetchpriority", "high"); expect(firstCover).toHaveAttribute("fetchpriority", "high");
}); });
}); });
describe("CasesPage: prioridade da capa do primeiro cartão", () => { 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 { container } = render(<CasesPage />);
const hero = container.querySelector("section"); const [first, ...others] = clientCasesForListing;
expect(hero?.querySelector("img")).toHaveAttribute("loading", "eager"); const firstCover = clientCard(container, first.id).querySelector(
for (const { id } of clientCases) { ".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"); const cover = clientCard(container, id).querySelector(".ds-bframe img");
expect(cover).toHaveAttribute("loading", "lazy"); expect(cover).toHaveAttribute("loading", "lazy");
expect(cover).not.toHaveAttribute("fetchpriority"); 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 { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { import {
buttonLabels,
type CaseStudyContent, type CaseStudyContent,
clientCases, clientCasesForListing,
labCases, labCases,
siteUrl, siteUrl,
} from "@/lib/site-content"; } from "@/lib/site-content";
import { CASES_SUBJECT } from "@/lib/whatsapp"; import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink"; import { CaseCoverLink } from "./_components/CaseCoverLink";
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles"; import { compactHero } from "./_components/hero-styles";
import { getMosaicPosters, PosterMosaic } from "./_components/PosterMosaic";
const pageUrl = `${siteUrl}/cases`; const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas"; const pageTitle = "Cases: sites e sistemas para empresas";
const pageDescription = 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."; "Cases de sites e sistemas para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
const allCases = [...clientCases, ...labCases]; /** 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 = { export const metadata: Metadata = {
title: pageTitle, title: pageTitle,
@@ -45,7 +48,7 @@ function ClientCaseCard({
priority = false, priority = false,
}: { }: {
caseStudy: CaseStudyContent; 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; priority?: boolean;
}) { }) {
return ( return (
@@ -64,7 +67,8 @@ function ClientCaseCard({
href={`/cases/${caseStudy.id}`} href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight} className={secondaryButtonLight}
> >
Ver o case {buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink> </HoverPrefetchLink>
{caseStudy.demoUrl ? ( {caseStudy.demoUrl ? (
<a <a
@@ -73,7 +77,8 @@ function ClientCaseCard({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Abrir o site {buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a> </a>
) : null} ) : null}
</div> </div>
@@ -97,16 +102,14 @@ function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
href={`/cases/${caseStudy.id}`} href={`/cases/${caseStudy.id}`}
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`} 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> </HoverPrefetchLink>
</li> </li>
); );
} }
export default function CasesPage() { 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 = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@graph": [ "@graph": [
@@ -137,14 +140,11 @@ export default function CasesPage() {
<main> <main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script> <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 <section
className={`ds-section ds-section-dark ${pageTop} ${ className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
hasMosaic ? "" : compactHero
}`}
> >
<div <div className="ds-section-shell">
className={`ds-section-shell ${hasMosaic ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]"> <div className="max-w-[860px]">
<Breadcrumb <Breadcrumb
items={[ items={[
@@ -157,10 +157,9 @@ export default function CasesPage() {
Veja o que já fiz para empresas. Veja o que já fiz para empresas.
</h1> </h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]"> <p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{pageDescription} {pageLead}
</p> </p>
</div> </div>
<PosterMosaic posters={posters} />
</div> </div>
</section> </section>
@@ -170,11 +169,11 @@ export default function CasesPage() {
Projetos de clientes Projetos de clientes
</h2> </h2>
<div className="ds-grid-2 mt-sp-8 items-start"> <div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy, index) => ( {clientCasesForListing.map((caseStudy, index) => (
<ClientCaseCard <ClientCaseCard
key={caseStudy.id} key={caseStudy.id}
caseStudy={caseStudy} caseStudy={caseStudy}
priority={index === 0 && !hasMosaic} priority={index === 0}
/> />
))} ))}
</div> </div>
@@ -203,7 +202,7 @@ export default function CasesPage() {
Quer um projeto parecido com algum destes? Quer um projeto parecido com algum destes?
</h2> </h2>
<p className="ds-copy max-w-[60ch]"> <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. formulário.
</p> </p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3"> <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" placement="cases_hub_footer"
className={primaryButton} className={primaryButton}
> >
Conversar sobre meu projeto {buttonLabels.contact}
</WhatsAppLink> </WhatsAppLink>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}> <HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
Ver os serviços {buttonLabels.allServices}
</HoverPrefetchLink> </HoverPrefetchLink>
</div> </div>
</div> </div>
+13 -9
View File
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb"; import { Breadcrumb } from "./Breadcrumb";
/** /**
* Testes de caminhos-base de Breadcrumb: o primeiro item não tem separador, * Testes de caminhos-base de Breadcrumb: a barra de separação vem depois de cada item menos do último (a quebra de linha
* os seguintes têm, e só o último é a página atual (inclusive quando é o único). * no celular nunca deixa uma linha começando por "/"), e só o último é a página atual (inclusive quando é o único).
*/ */
describe("Breadcrumb", () => { 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( const { container } = render(
<Breadcrumb <Breadcrumb
items={[ items={[
@@ -20,15 +20,19 @@ describe("Breadcrumb", () => {
const entries = Array.from(container.querySelectorAll("li")); const entries = Array.from(container.querySelectorAll("li"));
expect(entries).toHaveLength(3); expect(entries).toHaveLength(3);
expect(entries[0].querySelector("span[aria-hidden='true']")).toBeNull(); expect(
entries[0].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect( expect(
entries[1].querySelector("span[aria-hidden='true']"), entries[1].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/"); ).toHaveTextContent("/");
expect( expect(entries[2].querySelector("span[aria-hidden='true']")).toBeNull();
entries[2].querySelector("span[aria-hidden='true']"), // o nome vem antes da barra em todos os itens, e nenhum item abre por "/"
).toHaveTextContent("/"); for (const entry of entries) {
expect(entries[0].className).toBe(""); expect(entry.firstElementChild?.tagName).toBe("A");
expect(entries[1].className).toContain("flex"); 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", () => { 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. * 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). * 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({ export function Breadcrumb({
items, items,
@@ -11,13 +13,9 @@ export function Breadcrumb({
}) { }) {
return ( return (
<nav aria-label="Trilha de navegação" className="mb-sp-6"> <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) => ( {items.map(({ label, href }, index) => (
<li <li key={href} className="flex items-center gap-sp-2">
key={href}
className={index > 0 ? "flex items-center gap-sp-2" : undefined}
>
{index > 0 ? <span aria-hidden="true">/</span> : null}
<HoverPrefetchLink <HoverPrefetchLink
aria-current={index === items.length - 1 ? "page" : undefined} aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text" className="ds-link ds-link-text"
@@ -25,6 +23,9 @@ export function Breadcrumb({
> >
{label} {label}
</HoverPrefetchLink> </HoverPrefetchLink>
{index < items.length - 1 ? (
<span aria-hidden="true">/</span>
) : null}
</li> </li>
))} ))}
</ol> </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 { fireEvent, render, screen, within } from "@testing-library/react";
import { trackCvDownload } from "@/lib/analytics"; import { trackCvDownload } from "@/lib/analytics";
import { buttonLabels } from "@/lib/site-content";
import CurriculoPage, { metadata } from "./page"; import CurriculoPage, { metadata } from "./page";
jest.mock("@/lib/analytics", () => ({ trackCvDownload: jest.fn() })); 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("href", "/curriculo");
expect(breadcrumbLinks[1]).toHaveAttribute("aria-current", "page"); 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 }); const consultingLink = screen.getByRole("link", { name: /consultoria/i });
expect(hiringLink).toHaveAttribute("href", "/#contact"); expect(hiringLink).toHaveAttribute("href", "/#contact");
expect(consultingLink).toHaveAttribute("href", "/servicos"); 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"); expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
// limão só para o contato // limão só para o contato
expect(pdf).not.toHaveClass("ds-link-accent"); 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", "ds-link-accent",
); );
+2 -2
View File
@@ -10,7 +10,7 @@ import {
buildPageSocialMetadata, buildPageSocialMetadata,
buildProfilePageJsonLd, buildProfilePageJsonLd,
} from "@/lib/seo"; } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content"; import { buttonLabels, siteUrl } from "@/lib/site-content";
import CvDownloadLink from "./CvDownloadLink"; import CvDownloadLink from "./CvDownloadLink";
const pageUrl = `${siteUrl}/curriculo`; const pageUrl = `${siteUrl}/curriculo`;
@@ -68,7 +68,7 @@ export default function CurriculoPage() {
className="ds-link ds-link-accent" className="ds-link ds-link-accent"
href="/#contact" href="/#contact"
> >
Falar sobre uma oportunidade {buttonLabels.contact}
</HoverPrefetchLink> </HoverPrefetchLink>
<CvDownloadLink className="ds-link ds-link-muted"> <CvDownloadLink className="ds-link ds-link-muted">
Baixar currículo em PDF 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. * 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", () => ({ jest.mock("next/navigation", () => ({
notFound: jest.fn(() => { notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND"); throw new Error("NEXT_NOT_FOUND");
}), }),
usePathname: () => "/servicos",
})); }));
beforeEach(() => { beforeEach(() => {
@@ -33,20 +35,27 @@ describe("ServicePage: caminhos que o teste principal não cobre", () => {
expect(notFound).toHaveBeenCalledTimes(1); 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"); const service = getServicePage("criacao-de-sites");
if (!service?.faq?.length) throw new Error("Missing service FAQ"); if (!service?.faq?.length) throw new Error("Missing service FAQ");
await renderService(service.slug); const { container } = await renderService(service.slug);
expect( expect(
screen.getByRole("heading", { level: 2, name: FAQ_HEADING }), screen.getByRole("heading", { level: 2, name: FAQ_HEADING }),
).toBeVisible(); ).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) { for (const item of service.faq) {
expect( const details = screen.getByText(item.question).closest("details");
screen.getByRole("heading", { level: 3, name: item.question }), expect(details).not.toBeNull();
).toBeVisible(); 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 () => { 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 { render, screen, within } from "@testing-library/react";
import { SEO_IDS } from "@/lib/seo"; import { SEO_IDS } from "@/lib/seo";
import { import {
buttonLabels,
caseStudies, caseStudies,
clientCases, clientCasesForListing,
getRelatedCases, getRelatedCases,
getServicePage, getServicePage,
isClientCase, isClientCase,
serviceOffers,
servicePages, servicePages,
siteUrl, siteUrl,
} from "@/lib/site-content"; } from "@/lib/site-content";
import ServicesIndexPage from "../page"; import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } 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) { async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) }); 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"); const service = getServicePage("consultoria-nextjs");
if (!service) throw new Error("Missing representative service"); 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(); 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) { for (const cta of primaryCtas) {
const destination = new URL(cta.getAttribute("href") ?? ""); const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me"); 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")); 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 const expectedCaseLinks = service.relatedCaseIds
.filter((id) => id !== heroId)
.map((id) => { .map((id) => {
const caseStudy = caseStudies.find((item) => item.id === id); const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing related case ${id}`); if (!caseStudy) throw new Error(`Missing related case ${id}`);
return { return {
name: `Ler case: ${caseStudy.title}`, name: `Ver case ${caseStudy.title}`,
href: `/cases/${id}`, href: `/cases/${id}`,
}; };
}) })
.sort((a, b) => a.href.localeCompare(b.href)); .sort((a, b) => a.href.localeCompare(b.href));
const internalCaseLinks = screen.getAllByRole("link", { const internalCaseLinks = caseCardLinks(container);
name: /^Ler case:/,
});
expect( expect(
internalCaseLinks internalCaseLinks
.map((link) => ({ .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 { container } = render(<ServicesIndexPage />);
const hero = container.querySelector("section"); 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); expect(posters).toHaveLength(3);
for (const [index, poster] of posters.entries()) { for (const [index, poster] of posters.entries()) {
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain( expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
`/videos/${clientCases[index].id}/desktop.webp`, `/videos/${clientCasesForListing[index].id}/desktop.webp`,
); );
expect(poster).toHaveAttribute("alt", ""); 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 // só o primeiro, o maior, carrega na hora e com prioridade alta
expect(posters[0]).toHaveAttribute("fetchpriority", "high"); expect(posters[0]).toHaveAttribute("fetchpriority", "high");
expect(posters[1]).toHaveAttribute("loading", "lazy"); 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/); expect(container.textContent).not.toMatch(/perto de Garuva/);
// as capas dos projetos, mais abaixo, têm a largura da coluna e carregam preguiçosas // nenhum cliente do mosaico se repete mais abaixo: as ofertas citam os cases só por link, sem capa
const cover = container.querySelector("#projetos .ds-bframe img"); expect(container.querySelectorAll(".ds-bframe")).toHaveLength(0);
expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/); expect(container.querySelectorAll("img")).toHaveLength(3);
expect(cover).toHaveAttribute("loading", "lazy");
}); });
it("keeps hub cards and structured items aligned", () => { it("keeps hub cards and structured items aligned", () => {
@@ -256,10 +284,25 @@ it("keeps hub cards and structured items aligned", () => {
expect([...structuredUrls].sort()).toEqual( expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(), publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
); );
const visibleCards = itemList.itemListElement.map(({ name }) => // as duas ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
screen.getByRole("link", { name }), const offerLinks = serviceOffers
); .filter(({ href }) => publicServiceHrefs.includes(href))
const visibleHrefs = visibleCards.map((link) => link.getAttribute("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(visibleHrefs).toHaveLength(6);
expect(new Set(visibleHrefs).size).toBe(6); expect(new Set(visibleHrefs).size).toBe(6);
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort()); 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 />); const { container } = render(<ServicesIndexPage />);
for (const id of [ const offersHeading = screen.getByRole("heading", {
"sev-modaintima", level: 2,
"lavacao-047", name: "Três jeitos de começar.",
"sc-plus", });
"as-servicos", const othersHeading = screen.getByRole("heading", {
]) { level: 2,
const link = container.querySelector(`a[href="/cases/${id}"]`); 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( expect(
link?.closest("article")?.querySelector(".ds-bframe img"), within(card).getByRole("heading", { level: 3, name: offer.name }),
).not.toBeNull(); ).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( expect(container.textContent).not.toMatch(
/sem promessas|Demonstração publicada|limites|indispon/i, /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); ).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( 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([ expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
"/cases/sc-plus", "/cases/sc-plus",
"/cases/as-servicos", "/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 hero = container.querySelector("section");
const heroFrame = hero?.querySelector(".ds-bframe"); const heroFrame = hero?.querySelector(".ds-bframe");
expect(heroFrame).not.toBeNull(); expect(heroFrame).not.toBeNull();
expect(heroFrame?.querySelector("img")).toHaveAttribute( expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt", "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"); const { container } = await renderService("arquitetura-de-software");
// nenhum case de cliente é um diagnóstico: só a experiência na Apolo, em texto // nenhum case de cliente é um diagnóstico: só a experiência na Apolo, em texto
expect( expect(
screen.getByRole("heading", { name: "Onde já apliquei isso" }), screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible(); ).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
).toBeNull();
expect(screen.getByText(/Apolo Energia/)).toBeVisible(); expect(screen.getByText(/Apolo Energia/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull(); expect(screen.queryByRole("link", { name: /^Ver case / })).toBeNull();
expect( expect(
screen.queryByRole("link", { name: "Ver projetos parecidos" }), screen.queryByRole("link", { name: "Ver projetos parecidos" }),
).toBeNull(); ).toBeNull();
expect(screen.queryByText(/SEV Moda Íntima/)).toBeNull(); expect(screen.queryByText(/SEV Moda Íntima/)).toBeNull();
expect(container.querySelector(".ds-bframe")).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"); const { container } = await renderService("tauri-rust");
expect( expect(
screen.getByRole("link", { name: "Ler case: MinGW GCC Installer" }), screen.getByRole("link", { name: "Ver case MinGW GCC Installer" }),
).toHaveAttribute("href", "/cases/gcc-installer"); ).toHaveAttribute("href", "/cases/gcc-installer");
expect( expect(
screen.getByText("MinGW GCC Installer · Projeto próprio"), screen.getByText("MinGW GCC Installer · Projeto próprio"),
).toBeVisible(); ).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( expect(
screen.getByRole("link", { name: "Ver projetos parecidos" }), screen.getByRole("link", { name: "Ver projetos parecidos" }),
).toHaveAttribute("href", "#casos-relacionados"); ).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(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 () => { it("keeps the hero in two columns with a cover or a diagram, and in one lower column only when it has neither", async () => {
for (const slug of [ const diagrams = {
"tauri-rust", "tauri-rust": /aplicativo no computador/,
"integracao-hardware-software-iot", "integracao-hardware-software-iot": /dispositivo envia as leituras/,
"arquitetura-de-software", "arquitetura-de-software": /interface chama a API/,
]) { } as const;
for (const [slug, label] of Object.entries(diagrams)) {
const { container, unmount } = await renderService(slug); const { container, unmount } = await renderService(slug);
const hero = container.querySelector("section"); 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?.className).toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(screen.queryByRole("img")).toBeNull();
unmount(); unmount();
} finally {
bare.diagram = originalDiagram;
} }
const withMedia = await renderService("criacao-de-sites"); 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 () => { it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => {
const { container } = await renderService("integracao-hardware-software-iot"); 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([ expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab", "/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 () => { 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; if (relatedCaseIds.length === 0) continue;
const { unmount } = await renderService(slug); const { container, unmount } = await renderService(slug);
for (const id of relatedCaseIds) { for (const id of relatedCaseIds) {
const sentence = caseRelevance[id]; const sentence = caseRelevance[id];
expect(sentence).toBeTruthy(); expect(sentence).toBeTruthy();
const card = screen.getByText(sentence).closest("article"); const link = screen.getByRole("link", {
expect(card).toContainElement( name: `Ver case ${caseStudies.find((c) => c.id === id)?.title}`,
screen.getByRole("link", { });
name: `Ler 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(); 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.textContent).toMatch(/Desde 2022/);
expect(paragraph.closest("a")).toBeNull(); expect(paragraph.closest("a")).toBeNull();
expect(paragraph.querySelector("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", { const block = screen.getByRole("heading", {
name: "Onde já apliquei isso", name: "Como eu trabalho nisso",
}); });
expect(isBefore(block, paragraph)).toBe(true); expect(isBefore(block, paragraph)).toBe(true);
expect( 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 () => { it("puts the FAQ before the next step and ends every service page with the same contact button", async () => {
for (const { slug, faq, ctaLabel } of servicePages) { for (const { slug, faq } of servicePages) {
const { container, unmount } = await renderService(slug); const { container, unmount } = await renderService(slug);
const nextStep = screen.getByRole("heading", { 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; const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep); expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", { const cta = within(last as HTMLElement).getByRole("link", {
name: `${ctaLabel} no WhatsApp`, name: buttonLabels.contact,
}); });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe( expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me", "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 ?? []) { for (const question of faq ?? []) {
expect( expect(isBefore(screen.getByText(question.question), cta)).toBe(true);
isBefore(screen.getByRole("heading", { name: 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(); unmount();
} }
}); });
+35 -15
View File
@@ -11,6 +11,7 @@ import {
primaryButton, primaryButton,
secondaryButtonDark, secondaryButtonDark,
} from "@/app/components/button-styles"; } from "@/app/components/button-styles";
import { Faq } from "@/app/components/Faq";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink"; import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld"; import { safeJsonLd } from "@/lib/jsonld";
@@ -20,12 +21,14 @@ import {
buildServiceJsonLd, buildServiceJsonLd,
} from "@/lib/seo"; } from "@/lib/seo";
import { import {
buttonLabels,
caseStudies,
getRelatedCases, getRelatedCases,
getServicePage, getServicePage,
servicePages, servicePages,
siteUrl, siteUrl,
} from "@/lib/site-content"; } from "@/lib/site-content";
import { ServiceFaq } from "../_components/ServiceFaq"; import { ServiceDiagram } from "../_components/ServiceDiagram";
import { ServiceHeroCover } from "../_components/ServiceHeroCover"; import { ServiceHeroCover } from "../_components/ServiceHeroCover";
import { ServiceOffer } from "../_components/ServiceOffer"; import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases"; import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
@@ -75,9 +78,16 @@ export default async function ServicePage({ params }: ServicePageProps) {
} }
const relatedCases = getRelatedCases(service.relatedCaseIds); const relatedCases = getRelatedCases(service.relatedCaseIds);
const heroCase = relatedCases.find((caseStudy) => // capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo
getCaseMedia(caseStudy.id, "desktop"), 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 pageUrl = `${siteUrl}/servicos/${service.slug}`;
const jsonLd = { const jsonLd = {
@@ -98,11 +108,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
<section <section
className={`ds-section ds-section-dark ${pageTop} ${ className={`ds-section ds-section-dark ${pageTop} ${
heroCase ? "" : compactHero hasHeroSide ? "" : compactHero
}`} }`}
> >
<div <div
className={`ds-section-shell ${heroCase ? heroWithMediaGrid : ""}`} className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
> >
<div className="max-w-[860px]"> <div className="max-w-[860px]">
<Breadcrumb <Breadcrumb
@@ -113,7 +123,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
]} ]}
/> />
<p className="ds-eyebrow">{service.eyebrow}</p> <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} {service.headline}
</h1> </h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]"> <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" placement="service_detail_hero"
className={primaryButton} className={primaryButton}
> >
{service.ctaLabel} no WhatsApp {buttonLabels.contact}
</WhatsAppLink> </WhatsAppLink>
{relatedCases.length > 0 ? ( {hasCaseCards ? (
<a href="#casos-relacionados" className={secondaryButtonDark}> <a href="#casos-relacionados" className={secondaryButtonDark}>
Ver projetos parecidos Ver projetos parecidos
</a> </a>
) : null} ) : null}
</div> </div>
<p className="mt-sp-4 text-[15px] leading-6 text-white/70"> <p className="mt-sp-4 text-[15px] leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar O botão abre uma conversa no WhatsApp, sem formulário. Garuva e
preencher um formulário. Garuva e Joinville (SC) · atendimento Joinville (SC) · atendimento remoto em todo o Brasil.
remoto em todo o Brasil.
</p> </p>
</div> </div>
<ServiceHeroCover caseStudy={heroCase} /> {diagram ? (
<ServiceDiagram kind={diagram} />
) : (
<ServiceHeroCover
caseStudy={heroCase}
device={heroDevice}
relevance={
heroCase ? service.caseRelevance[heroCase.id] : undefined
}
/>
)}
</div> </div>
</section> </section>
@@ -147,6 +166,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
cases={relatedCases} cases={relatedCases}
relevance={service.caseRelevance} relevance={service.caseRelevance}
experienceNote={service.experienceNote} experienceNote={service.experienceNote}
heroCaseId={heroCase?.id}
/> />
<section className="ds-section"> <section className="ds-section">
@@ -232,7 +252,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<ServiceOffer service={service} /> <ServiceOffer service={service} />
<ServiceFaq faq={service.faq} /> <Faq items={service.faq} />
<section className="ds-section ds-section-dark"> <section className="ds-section ds-section-dark">
<div className="ds-section-shell"> <div className="ds-section-shell">
@@ -252,13 +272,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
placement="service_detail_footer" placement="service_detail_footer"
className={primaryButton} className={primaryButton}
> >
{service.ctaLabel} no WhatsApp {buttonLabels.contact}
</WhatsAppLink> </WhatsAppLink>
<HoverPrefetchLink <HoverPrefetchLink
href="/servicos" href="/servicos"
className={secondaryButtonDark} className={secondaryButtonDark}
> >
Ver todos os serviços {buttonLabels.allServices}
</HoverPrefetchLink> </HoverPrefetchLink>
</div> </div>
</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(); 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); 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", { 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}`); expect(link).toHaveAttribute("href", `/cases/${sev.id}`);
const image = screen.getByAltText(`Captura de tela do site ${sev.title}`); const image = screen.getByAltText(`Captura de tela do site ${sev.title}`);
expect(image).toHaveAttribute("loading", "eager"); expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high"); expect(image).toHaveAttribute("fetchpriority", "high");
expect(image).toHaveAttribute("sizes", coverSizes.hero); 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", () => { 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 { 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 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({ export function ServiceHeroCover({
caseStudy, caseStudy,
device = "desktop",
relevance,
}: { }: {
caseStudy?: CaseStudyContent; caseStudy?: CaseStudyContent;
device?: HeroDevice;
/** Por que o case interessa ao serviço, em uma frase. */
relevance?: string;
}) { }) {
if (!caseStudy) return null; 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 ( return (
<HoverPrefetchLink <figure
href={`/cases/${caseStudy.id}`} className={`min-w-0 ${device === "mobile" ? "mx-auto w-full max-w-[360px]" : ""}`}
className="block min-w-0"
> >
<span className="sr-only">Ver o case {caseStudy.title}</span> <HoverPrefetchLink
<CaseCover href={href}
id={caseStudy.id} aria-hidden="true"
title={caseStudy.title} tabIndex={-1}
demoUrl={caseStudy.demoUrl} className={
sizes={coverSizes.hero} media ? "mx-auto block w-[min(62vw,240px)] min-w-0" : "block min-w-0"
priority }
/> >
</HoverPrefetchLink> {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 sev = getCase("sev-modaintima"); // cliente, com site no ar
const asServicos = getCase("as-servicos"); // cliente, sem site público const asServicos = getCase("as-servicos"); // cliente, sem site público
const twins = getCase("twins-lab"); // laboratório, 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 = "Onde já apliquei isso";
const TITLE_WITHOUT_CLIENT = "Como eu trabalho nisso";
const NOTE = "Texto de experiência de teste."; const NOTE = "Texto de experiência de teste.";
beforeEach(() => { beforeEach(() => {
@@ -57,11 +59,12 @@ describe("ServiceRelatedCases", () => {
); );
expect( expect(
screen.getByRole("heading", { level: 2, name: TITLE }), screen.getByRole("heading", { level: 2, name: TITLE_WITHOUT_CLIENT }),
).toBeVisible(); ).toBeVisible();
expect(screen.queryByRole("heading", { name: TITLE })).toBeNull();
expect(screen.getByText(NOTE)).toBeVisible(); expect(screen.getByText(NOTE)).toBeVisible();
expect(container.querySelectorAll("article")).toHaveLength(0); 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", () => { 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"); if (!card) throw new Error("Missing case card");
expect(within(card).getByText(sev.headline)).toBeVisible(); expect(within(card).getByText(sev.headline)).toBeVisible();
// case de cliente: o kicker é só o nome // 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).queryByText(/Projeto próprio/)).toBeNull();
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible(); expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
expect( expect(
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }), within(card).getByRole("link", { name: `Ver case ${sev.title}` }),
).toHaveAttribute("href", `/cases/${sev.id}`); ).toHaveAttribute("href", `/cases/${sev.id}`);
expect( 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); ).toHaveAttribute("href", sev.demoUrl);
}); });
@@ -107,7 +111,7 @@ describe("ServiceRelatedCases", () => {
expect(note.querySelector("a")).toBeNull(); expect(note.querySelector("a")).toBeNull();
expect( expect(
note.compareDocumentPosition( note.compareDocumentPosition(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }), screen.getByRole("link", { name: `Ver case ${sev.title}` }),
) & Node.DOCUMENT_POSITION_FOLLOWING, ) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy(); ).toBeTruthy();
}); });
@@ -138,7 +142,7 @@ describe("ServiceRelatedCases", () => {
expect(container.querySelector(".ds-bframe")).toBeNull(); expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("img")).toBeNull();
expect( expect(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }), screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible(); ).toBeVisible();
}); });
@@ -152,6 +156,42 @@ describe("ServiceRelatedCases", () => {
); );
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible(); 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", () => { it("path: no relevance sentence for the case -> falls back to the case summary", () => {
@@ -172,7 +212,7 @@ describe("ServiceRelatedCases", () => {
); );
expect( expect(
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }), screen.getByRole("link", { name: `Ver case ${asServicos.title}` }),
).toBeVisible(); ).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull(); 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( expect(
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }), screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible(); ).toBeVisible();
expect(screen.getByText(note)).toBeVisible(); expect(screen.getByText(note)).toBeVisible();
expect(screen.queryByText(/Abra o case/)).toBeNull(); 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(/Abra o case/)).toBeVisible();
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible(); expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
expect( expect(
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }), screen.getByRole("link", { name: `Ver case ${caseStudy.title}` }),
).toHaveAttribute("href", `/cases/${caseStudy.id}`); ).toHaveAttribute("href", `/cases/${caseStudy.id}`);
}); });
@@ -1,7 +1,11 @@
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink"; import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { secondaryButtonLight, textLink } from "@/app/components/button-styles"; import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; 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({ function ServiceCaseCard({
caseStudy, caseStudy,
@@ -31,7 +35,8 @@ function ServiceCaseCard({
href={`/cases/${caseStudy.id}`} href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight} className={secondaryButtonLight}
> >
Ler case: {caseStudy.title} {buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink> </HoverPrefetchLink>
{caseStudy.demoUrl ? ( {caseStudy.demoUrl ? (
<a <a
@@ -40,7 +45,8 @@ function ServiceCaseCard({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Abrir o site: {caseStudy.title} {buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a> </a>
) : null} ) : null}
</div> </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, * Prova do serviço. Com algum case de cliente, é "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase * frase sobre o que ele tem a ver com ele, e a experiência profissional citada em texto (sem link). Sem case de cliente
* que manda abrir o case; sem case nem experiência, não mostra nada. * (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({ export function ServiceRelatedCases({
cases, cases: allCases,
relevance, relevance,
experienceNote, experienceNote,
heroCaseId,
}: { }: {
cases: readonly CaseStudyContent[]; cases: readonly CaseStudyContent[];
/** Por que cada case interessa a este serviço (chave: id do case). */ /** Por que cada case interessa a este serviço (chave: id do case). */
relevance: Readonly<Record<string, string>>; relevance: Readonly<Record<string, string>>;
experienceNote?: 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; const hasCases = cases.length > 0;
if (!hasCases && !experienceNote) return null; if (!hasCases && !experienceNote) return null;
const hasClientCase = cases.some(isClientCase);
return ( return (
<section <section
@@ -75,9 +89,13 @@ export function ServiceRelatedCases({
<div className="ds-section-shell"> <div className="ds-section-shell">
<div className="ds-section-header"> <div className="ds-section-header">
<div className="ds-section-meta"> <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"> <h2 id="casos-relacionados-titulo" className="ds-section-title">
Onde já apliquei isso {hasClientCase
? "Onde já apliquei isso"
: "Como eu trabalho nisso"}
</h2> </h2>
{hasCases ? ( {hasCases ? (
<p className="ds-section-description"> <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.", name: "Sites, sistemas e aplicativos sob medida.",
}), }),
).toBeVisible(); ).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( expect(
screen.getByRole("link", { name: "Ver projetos realizados" }), screen.getByRole("heading", {
).toHaveAttribute("href", "#projetos"); level: 2,
name: "Três jeitos de começar.",
}),
).toBeVisible();
}); });
}); });
+74 -171
View File
@@ -1,6 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { coverSizes } from "@/app/cases/_components/CaseCover";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { import {
compactHero, compactHero,
heroWithMediaGrid, heroWithMediaGrid,
@@ -14,47 +12,48 @@ import {
pageTop, pageTop,
primaryButton, primaryButton,
secondaryButtonDark, secondaryButtonDark,
textLink,
} from "@/app/components/button-styles"; } from "@/app/components/button-styles";
import { Faq } from "@/app/components/Faq";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink"; import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld"; import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; 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 { SERVICES_SUBJECT } from "@/lib/whatsapp";
import { ServiceOfferCards } from "./_components/ServiceOfferCards";
const pageUrl = `${siteUrl}/servicos`; const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Sites, sistemas e aplicativos sob medida"; const pageTitle = "Sites, sistemas e aplicativos sob medida";
const pageDescription = 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."; "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", question: "Quanto custa e quanto tempo leva?",
description: answer:
"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.", "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.",
href: "/servicos/criacao-de-sites",
linkLabel: "Conhecer a criação de sites",
service: "Sites profissionais",
ctaLabel: "Conversar sobre meu site",
}, },
{ {
title: "Preciso organizar minha operação", question: "Preciso estar em Joinville ou em Garuva?",
description: answer:
"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.", "Não. O atendimento é remoto, com conversas, demonstrações e revisões on-line para projetos em todo o Brasil.",
href: "/servicos/engenharia-de-software",
linkLabel: "Conhecer o desenvolvimento de sistemas",
service: "Sistemas web",
ctaLabel: "Conversar sobre meu sistema",
}, },
{ {
title: "Quero desenvolver um aplicativo", question: "E se eu já tiver um site ou sistema?",
description: answer:
"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.", "Avalio a base atual para propor melhorias, correções ou integrações. Recomeçar do zero não é uma condição para trabalhar comigo.",
href: "/servicos/engenharia-de-software",
linkLabel: "Entender o escopo de um app mobile",
service: "Aplicativos mobile e desktop",
ctaLabel: "Conversar sobre meu aplicativo",
}, },
]; ];
@@ -70,8 +69,9 @@ export const metadata: Metadata = {
}; };
export default function ServicesIndexPage() { export default function ServicesIndexPage() {
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia // 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)
const posters = getMosaicPosters(clientCases.map(({ id }) => id)); // 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 hasMosaic = posters.length > 0;
const jsonLd = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
@@ -132,18 +132,17 @@ export default function ServicesIndexPage() {
service={SERVICES_SUBJECT} service={SERVICES_SUBJECT}
placement="service_hub_hero" placement="service_hub_hero"
> >
Falar com Evandro no WhatsApp {buttonLabels.contact}
</WhatsAppLink> </WhatsAppLink>
<a <HoverPrefetchLink
href="#projetos" href="/cases"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`} className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
> >
Ver projetos realizados {buttonLabels.allCases}
</a> </HoverPrefetchLink>
</div> </div>
<p className="ds-copy max-w-[62ch]"> <p className="ds-copy max-w-[62ch]">
Sou Evandro, desenvolvedor de software, e a conversa é direta Sou Evandro, desenvolvedor de software, e a conversa é comigo.
comigo.
</p> </p>
<p className="mt-sp-2 text-[15px] leading-6 text-white/70"> <p className="mt-sp-2 text-[15px] leading-6 text-white/70">
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil Garuva e Joinville (SC) · atendimento remoto em todo o Brasil
@@ -153,155 +152,35 @@ export default function ServicesIndexPage() {
</div> </div>
</section> </section>
<section className="ds-section" aria-labelledby="necessidade-title"> <section className="ds-section" aria-labelledby="ofertas-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"
>
<div className="ds-section-shell"> <div className="ds-section-shell">
<h2 id="projetos-title" className="ds-section-title"> <h2
Veja o trabalho antes de conversar. id="ofertas-title"
className="ds-section-title max-w-[26ch] text-balance"
>
Três jeitos de começar.
</h2> </h2>
<p className="ds-copy max-w-[64ch]"> <p className="ds-copy max-w-[64ch]">
Projetos feitos para empresas. Abra o case para ver o que foi feito Não precisa chegar com um projeto técnico pronto. Escolha o que mais
e como o site ficou. se parece com o seu caso; começamos pela sua rotina e pelo que
precisa mudar.
</p> </p>
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2"> <ServiceOfferCards offers={serviceOffers} />
{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>
</div> </div>
</section> </section>
<section className="ds-section" aria-labelledby="servicos-title"> <section className="ds-section" aria-labelledby="servicos-title">
<div className="ds-section-shell"> <div className="ds-section-shell">
<h2 id="servicos-title" className="ds-section-title"> <h2 id="servicos-title" className="ds-section-title">
Quer aprofundar o escopo técnico? Outros serviços
</h2> </h2>
<p className="ds-copy max-w-[64ch]"> <p className="ds-copy max-w-[64ch]">
Consulte as áreas de atuação, incluindo desktop, integrações, Consultoria em Next.js, aplicativos desktop, integração de
consultoria e IoT. Cada página explica o que posso fazer por você. equipamentos e diagnóstico de arquitetura. Cada página explica o que
posso fazer por você.
</p> </p>
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]"> <ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service) => ( {otherServices.map((service) => (
<li <li
key={service.href} 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" 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> </div>
</section> </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"> <section className="ds-section ds-section-dark">
<div className="ds-section-shell"> <div className="ds-section-shell">
<h2 className="ds-section-title max-w-[24ch]"> <h2 className="ds-section-title max-w-[24ch]">
@@ -334,7 +237,7 @@ export default function ServicesIndexPage() {
service={SERVICES_SUBJECT} service={SERVICES_SUBJECT}
placement="service_hub_footer" placement="service_hub_footer"
> >
Conversar com Evandro no WhatsApp {buttonLabels.contact}
</WhatsAppLink> </WhatsAppLink>
</div> </div>
</section> </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"; 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. */ /** 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); 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", () => { it("gives every route the modification date of its own content, equal on every build", () => {
const dates = sitemap().map((entry) => String(entry.lastModified)); const day = (value: Date | string | undefined) =>
expect(new Set(dates).size).toBe(1); 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 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 { export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ url: siteUrl }, { url: siteUrl, lastModified: dateOf(pageUpdatedAt.home) },
{ url: `${siteUrl}/curriculo` }, {
{ url: `${siteUrl}/privacidade` }, url: `${siteUrl}/curriculo`,
{ url: `${siteUrl}/servicos` }, lastModified: dateOf(pageUpdatedAt.curriculo),
{ url: `${siteUrl}/cases` }, },
{ url: `${siteUrl}/ia` }, {
...servicePages.map(({ slug }) => ({ 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}`, url: `${siteUrl}/servicos/${slug}`,
lastModified: dateOf(updatedAt),
})), })),
...caseStudies.map(({ id }) => ({ ...caseStudies.map(({ id, updatedAt }) => ({
url: `${siteUrl}/cases/${id}`, url: `${siteUrl}/cases/${id}`,
lastModified: dateOf(updatedAt),
})), })),
].map((entry) => ({ ...entry, lastModified })); ];
} }
+48
View File
@@ -4,6 +4,7 @@ import {
buildCaseJsonLd, buildCaseJsonLd,
buildPageSocialMetadata, buildPageSocialMetadata,
buildPersonJsonLd, buildPersonJsonLd,
buildProfessionalServiceJsonLd,
buildProfilePageJsonLd, buildProfilePageJsonLd,
buildServiceJsonLd, buildServiceJsonLd,
buildWebSiteJsonLd, buildWebSiteJsonLd,
@@ -14,6 +15,7 @@ import {
import { import {
caseStudies, caseStudies,
isClientCase, isClientCase,
serviceOffers,
servicePages, servicePages,
siteName, siteName,
siteUrl, siteUrl,
@@ -27,6 +29,7 @@ it("uses one canonical identity across website, profile and service", () => {
website: `${siteUrl}/#website`, website: `${siteUrl}/#website`,
person: `${siteUrl}/#person`, person: `${siteUrl}/#person`,
profilePage: `${siteUrl}/curriculo#profile-page`, profilePage: `${siteUrl}/curriculo#profile-page`,
professionalService: `${siteUrl}/#professional-service`,
}); });
expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person }); expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person });
expect(buildProfilePageJsonLd()).toMatchObject({ expect(buildProfilePageJsonLd()).toMatchObject({
@@ -98,6 +101,47 @@ it("builds a canonical service with only the verified service area", () => {
}); });
}); });
it("describes the business as a ProfessionalService with the service area and the three offers as a catalog", () => {
const business = buildProfessionalServiceJsonLd();
expect(business).toMatchObject({
"@type": "ProfessionalService",
"@id": SEO_IDS.professionalService,
name: siteName,
url: siteUrl,
founder: { "@id": SEO_IDS.person },
});
expect(business.areaServed.map(({ name }) => name)).toEqual([
"Garuva",
"Joinville",
"Santa Catarina",
"Brasil",
]);
const catalog = business.hasOfferCatalog;
expect(catalog["@type"]).toBe("OfferCatalog");
// as mesmas três ofertas da home, com o mesmo nome e a página de cada uma
expect(
catalog.itemListElement.map(({ itemOffered }) => itemOffered.name),
).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Revisão de sistema que já existe, inclusive feito com IA",
]);
expect(
catalog.itemListElement.map(({ itemOffered }) => itemOffered.url),
).toEqual(serviceOffers.map(({ href }) => `${siteUrl}${href}`));
for (const offer of catalog.itemListElement) {
expect(offer["@type"]).toBe("Offer");
expect(offer.itemOffered["@type"]).toBe("Service");
expect(offer.itemOffered.description.length).toBeGreaterThan(30);
}
// nada que o site não publique: sem preço, prazo, endereço de rua, telefone ou e-mail
expect(JSON.stringify(business)).not.toMatch(
/price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i,
);
});
function getCase(id: string) { function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id); const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`); if (!caseStudy) throw new Error(`Missing case ${id}`);
@@ -214,6 +258,10 @@ it("lists the client cases and the AI page, each with a fixed modification date"
false, false,
); );
} }
// a data é a do conteúdo de cada rota, não uma data única para o mapa inteiro
expect(
new Set(entries.map(({ lastModified }) => String(lastModified))).size,
).toBeGreaterThan(1);
}); });
it("gives every page a complete social card, so it never inherits the home one", () => { it("gives every page a complete social card, so it never inherits the home one", () => {
+38
View File
@@ -3,6 +3,7 @@ import {
type CaseStudyContent, type CaseStudyContent,
isClientCase, isClientCase,
type ServicePageContent, type ServicePageContent,
serviceOffers,
siteName, siteName,
siteUrl, siteUrl,
} from "./site-content"; } from "./site-content";
@@ -13,6 +14,7 @@ export const SEO_IDS = {
website: `${siteUrl}/#website`, website: `${siteUrl}/#website`,
person: `${siteUrl}/#person`, person: `${siteUrl}/#person`,
profilePage: `${siteUrl}/curriculo#profile-page`, profilePage: `${siteUrl}/curriculo#profile-page`,
professionalService: `${siteUrl}/#professional-service`,
} as const; } as const;
export function buildWebSiteJsonLd() { export function buildWebSiteJsonLd() {
@@ -64,6 +66,42 @@ export function buildProfilePageJsonLd() {
} as const; } as const;
} }
/**
* O negócio como `ProfessionalService`: onde atende (Garuva, Joinville, Santa Catarina e o Brasil, de forma remota) e as três ofertas
* da home como catálogo. Sem endereço de rua, preço, prazo, telefone ou e-mail: o que não está publicado no site não entra nos
* dados estruturados. Só o construtor: quem o coloca na página é a home.
*/
export function buildProfessionalServiceJsonLd() {
return {
"@type": "ProfessionalService",
"@id": SEO_IDS.professionalService,
name: siteName,
url: siteUrl,
description:
"Sites, sistemas web e aplicativos sob medida, e revisão de sistemas que já existem, inclusive feitos com IA. Garuva e Joinville (SC), remoto em todo o Brasil.",
founder: { "@id": SEO_IDS.person },
areaServed: [
{ "@type": "City", name: "Garuva" },
{ "@type": "City", name: "Joinville" },
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
{ "@type": "Country", name: "Brasil" },
],
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Serviços de desenvolvimento de software",
itemListElement: serviceOffers.map((offer) => ({
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: offer.name,
description: offer.summary,
url: `${siteUrl}${offer.href}`,
},
})),
},
} as const;
}
export function buildServiceJsonLd(service: ServicePageContent) { export function buildServiceJsonLd(service: ServicePageContent) {
const pageUrl = `${siteUrl}/servicos/${service.slug}`; const pageUrl = `${siteUrl}/servicos/${service.slug}`;
+77
View File
@@ -0,0 +1,77 @@
/**
* Testes de caminhos-base da ordem de `clientCasesForListing` (lib/site-content.ts), com a lista de projetos de
* cliente trocada por um dublê de `isClientProject`.
*
* listingRank (CC 2): o id está em `clientListingOrder` (a posição dele) | não está (o fim, depois de todos). O
* `sort` é estável: os que não estão na lista ficam na ordem em que `caseStudies` os traz.
* Os dados reais só têm os quatro clientes da lista; o caminho do cliente fora da lista só existe aqui.
*/
const LISTED = ["lavacao-047", "sc-plus", "as-servicos", "sev-modaintima"];
function loadWithClients(clientIds: readonly string[] | "all") {
let loaded: typeof import("./site-content") | undefined;
jest.isolateModules(() => {
jest.doMock("./proof", () => ({
...jest.requireActual("./proof"),
isClientProject: ({ id }: { id: string }) =>
clientIds === "all" || clientIds.includes(id),
}));
loaded = require("./site-content");
});
jest.dontMock("./proof");
if (!loaded) throw new Error("site-content não carregou");
return loaded;
}
describe("clientCasesForListing: ordem", () => {
it("path: only listed clients -> the fixed order, the SEV last, whatever order the cases come in", () => {
const { clientCasesForListing, clientCases } = loadWithClients(LISTED);
expect(clientCasesForListing.map(({ id }) => id)).toEqual(LISTED);
expect(clientCasesForListing).toHaveLength(clientCases.length);
});
it("path: a client that is not in the order list -> goes after all the listed ones, the SEV included", () => {
const { clientCasesForListing, caseStudies } = loadWithClients([
...LISTED,
"gcc-installer",
]);
if (!caseStudies.some(({ id }) => id === "gcc-installer"))
throw new Error("Missing gcc-installer case");
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
...LISTED,
"gcc-installer",
]);
});
it("path: several clients outside the list -> they keep the order of caseStudies among themselves (stable sort)", () => {
const { clientCasesForListing, caseStudies } = loadWithClients("all");
const unlisted = clientCasesForListing
.map(({ id }) => id)
.filter((id) => !LISTED.includes(id));
expect(unlisted.length).toBeGreaterThan(1);
expect(unlisted).toEqual(
caseStudies.map(({ id }) => id).filter((id) => !LISTED.includes(id)),
);
expect(
clientCasesForListing.slice(0, LISTED.length).map(({ id }) => id),
).toEqual(LISTED);
});
it("path: no client at all -> an empty list, no crash", () => {
const { clientCasesForListing } = loadWithClients([]);
expect(clientCasesForListing).toEqual([]);
});
it("path: the real data -> the SEV is the last client, as the mosaic and /cases expect", () => {
jest.resetModules();
const { clientCasesForListing } =
jest.requireActual<typeof import("./site-content")>("./site-content");
expect(clientCasesForListing.at(-1)?.id).toBe("sev-modaintima");
});
});
+173 -11
View File
@@ -2,12 +2,16 @@ import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import portfolioData from "@/public/portfolio-data.json"; import portfolioData from "@/public/portfolio-data.json";
import { import {
buttonLabels,
caseStudies, caseStudies,
clientCases, clientCases,
clientCasesForListing,
getRelatedCases, getRelatedCases,
getRelatedServices, getRelatedServices,
isClientCase, isClientCase,
labCases, labCases,
pageUpdatedAt,
serviceOffers,
servicePages, servicePages,
} from "./site-content"; } from "./site-content";
@@ -89,17 +93,25 @@ it("mentions the region and a benefit in the title and description of every serv
expect(new Set(descriptions).size).toBe(descriptions.length); expect(new Set(descriptions).size).toBe(descriptions.length);
}); });
it("maps each service to its client cases", () => { it("maps each service to its client cases, with the SEV only where it is the proof: the systems service", () => {
expect(getService("criacao-de-sites").relatedCaseIds).toEqual(clientIds); expect(getService("criacao-de-sites").relatedCaseIds).toEqual([
"lavacao-047",
"sc-plus",
"as-servicos",
]);
expect(getService("engenharia-de-software").relatedCaseIds).toEqual([ expect(getService("engenharia-de-software").relatedCaseIds).toEqual([
"sev-modaintima", "sev-modaintima",
"lavacao-047", "lavacao-047",
]); ]);
expect(getService("consultoria-nextjs").relatedCaseIds).toEqual([ expect(getService("consultoria-nextjs").relatedCaseIds).toEqual([
"sev-modaintima",
"lavacao-047",
"sc-plus", "sc-plus",
"lavacao-047",
]); ]);
expect(
servicePages
.filter(({ relatedCaseIds }) => relatedCaseIds.includes("sev-modaintima"))
.map(({ slug }) => slug),
).toEqual(["engenharia-de-software"]);
// nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo // nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo
expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]); expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]);
expect(getService("arquitetura-de-software").caseRelevance).toEqual({}); expect(getService("arquitetura-de-software").caseRelevance).toEqual({});
@@ -112,16 +124,163 @@ it("maps each service to its client cases", () => {
it("says what the Next.js service cases really prove: sites built by me, not a diagnosis", () => { it("says what the Next.js service cases really prove: sites built by me, not a diagnosis", () => {
const { caseRelevance } = getService("consultoria-nextjs"); const { caseRelevance } = getService("consultoria-nextjs");
for (const id of ["sev-modaintima", "lavacao-047"]) { expect(caseRelevance["lavacao-047"]).toMatch(/construí em Next\.js 16/);
expect(caseRelevance[id]).toMatch(/construí em Next\.js 16/); expect(caseRelevance["sc-plus"]).toMatch(/Next\.js 15/);
expect(caseRelevance[id]).not.toMatch(/diagn|conheço por dentro/i); for (const sentence of Object.values(caseRelevance)) {
expect(sentence).not.toMatch(/diagn|conheço por dentro/i);
} }
}); });
it("is honest that the desktop case is a Windows app made in Python, not in Tauri", () => { it("calls the desktop case a personal Windows app project, without claiming it is made in Tauri", () => {
expect(getService("tauri-rust").caseRelevance["gcc-installer"]).toMatch( const sentence = getService("tauri-rust").caseRelevance["gcc-installer"];
/Windows.*Python/, expect(sentence).toMatch(/projeto próprio de aplicativo para Windows/);
expect(sentence).not.toMatch(/Tauri|Python/);
});
it("opens the two offer services with the name of the offer as the h1, keeping the search title", () => {
expect(getService("criacao-de-sites").headline).toBe(serviceOffers[0].name);
expect(getService("criacao-de-sites").headline).toBe(
"Site que traz pedidos pelo WhatsApp",
); );
expect(getService("engenharia-de-software").headline).toBe(
serviceOffers[1].name,
);
expect(getService("engenharia-de-software").headline).toBe(
"Sistema para tirar a operação da planilha",
);
expect(getService("criacao-de-sites").metaTitle).toBe(
"Criação de sites em Joinville e SC",
);
expect(getService("engenharia-de-software").metaTitle).toBe(
"Sistemas web e aplicativos sob medida",
);
});
it("picks the cover by subject: sites -> Lavação 047, Next.js -> SC Plus, systems -> SEV on the phone, nothing for the rest", () => {
expect(getService("criacao-de-sites").heroCase).toEqual({
id: "lavacao-047",
device: "desktop",
});
expect(getService("consultoria-nextjs").heroCase).toEqual({
id: "sc-plus",
device: "desktop",
});
expect(getService("engenharia-de-software").heroCase).toEqual({
id: "sev-modaintima",
device: "mobile",
});
for (const slug of [
"tauri-rust",
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
expect(getService(slug).heroCase).toBeUndefined();
}
// a capa é sempre um dos cases relacionados, e nenhuma capa se repete entre os serviços
const heroIds = servicePages.flatMap(({ heroCase }) =>
heroCase ? [heroCase.id] : [],
);
expect(new Set(heroIds).size).toBe(heroIds.length);
for (const { heroCase, relatedCaseIds } of servicePages) {
if (heroCase) expect(relatedCaseIds).toContain(heroCase.id);
}
});
it("gives each service without a client cover a flow diagram instead, and never both", () => {
expect(getService("tauri-rust").diagram).toBe("desktop");
expect(getService("integracao-hardware-software-iot").diagram).toBe("iot");
expect(getService("arquitetura-de-software").diagram).toBe("arquitetura");
for (const { heroCase, diagram } of servicePages) {
expect(Boolean(heroCase) !== Boolean(diagram)).toBe(true);
}
});
it("lists the client cases for the mosaic and /cases with the SEV last, never first", () => {
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
"lavacao-047",
"sc-plus",
"as-servicos",
"sev-modaintima",
]);
expect(clientCasesForListing).toHaveLength(clientCases.length);
});
it("keeps one list of button labels: 'Falar sobre meu projeto' for contact and 'Ver case' for a case", () => {
expect(buttonLabels.contact).toBe("Falar sobre meu projeto");
expect(buttonLabels.case).toBe("Ver case");
expect(Object.values(buttonLabels).join("|")).not.toMatch(
/Ver o case|Ler case|Conversar|oportunidade/,
);
});
it("reduces 'direto' and 'diretamente' in the case and service text", () => {
const text = JSON.stringify({ caseStudies, servicePages });
expect(text.match(/diret[oa]s?|diretamente/gi) ?? []).toEqual([]);
});
it("explains the three offers of the home with the same names, 3 or 4 deliverables and a place to see them", () => {
expect(serviceOffers.map(({ name }) => name)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Revisão de sistema que já existe, inclusive feito com IA",
]);
expect(serviceOffers.map(({ href }) => href)).toEqual([
"/servicos/criacao-de-sites",
"/servicos/engenharia-de-software",
"/ia",
]);
for (const offer of serviceOffers) {
expect(offer.deliverables.length).toBeGreaterThanOrEqual(3);
expect(offer.deliverables.length).toBeLessThanOrEqual(4);
expect(
offer.proof.caseIds.every((id) => caseStudies.some((c) => c.id === id)),
).toBe(true);
// sem número, preço, prazo ou depoimento inventados
const text = [
offer.name,
offer.summary,
...offer.deliverables,
offer.proof.note ?? "",
].join(" ");
expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/);
}
expect(serviceOffers[0].proof.caseIds).toEqual([
"lavacao-047",
"sev-modaintima",
]);
expect(serviceOffers[1].proof.caseIds).toEqual(["sev-modaintima"]);
expect(serviceOffers[1].proof.note).toMatch(/Apolo Energia/);
expect(serviceOffers[1].proof.note).toMatch(/uso interno/);
expect(serviceOffers[2].proof.caseIds).toEqual([]);
});
it("writes 2 or 3 capabilities for every client case, without numbers, and none for the laboratory", () => {
for (const caseStudy of clientCases) {
expect(caseStudy.capabilities?.length).toBeGreaterThanOrEqual(2);
expect(caseStudy.capabilities?.length).toBeLessThanOrEqual(3);
expect(JSON.stringify(caseStudy.capabilities)).not.toMatch(
/\d+\s?%|R\$|\d+\s?(clientes|pedidos|dias|vezes)/,
);
}
expect(
caseStudies.find(({ id }) => id === "lavacao-047")?.capabilities?.[0],
).toBe(
"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.",
);
for (const caseStudy of labCases) {
expect(caseStudy.capabilities).toBeUndefined();
}
});
it("dates every service, case and fixed page with a plain calendar day for the sitemap", () => {
const days = [
...servicePages.map(({ updatedAt }) => updatedAt),
...caseStudies.map(({ updatedAt }) => updatedAt),
...Object.values(pageUpdatedAt),
];
for (const day of days) expect(day).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// a política de privacidade informa a própria vigência: a data do mapa é a dela
expect(pageUpdatedAt.privacidade).toBe("2026-09-13");
}); });
it("places the services in Joinville and SC instead of only 'em Garuva'", () => { it("places the services in Joinville and SC instead of only 'em Garuva'", () => {
@@ -164,8 +323,11 @@ it("derives the exact related service set from the existing relationship", () =>
expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([ expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([
"integracao-hardware-software-iot", "integracao-hardware-software-iot",
]); ]);
expect(getRelatedServices("sev-modaintima").map(({ slug }) => slug)).toEqual([
"engenharia-de-software",
]);
expect( expect(
getRelatedServices("sev-modaintima") getRelatedServices("lavacao-047")
.map(({ slug }) => slug) .map(({ slug }) => slug)
.sort(), .sort(),
).toEqual( ).toEqual(
+186 -22
View File
@@ -4,6 +4,25 @@ import { isClientProject } from "./proof";
export const siteUrl = "https://evandro.dev.br"; export const siteUrl = "https://evandro.dev.br";
export const siteName = "Evandro.dev.br"; export const siteName = "Evandro.dev.br";
/** Aparelho cuja captura abre a página do serviço: o site no computador (moldura de navegador) ou no celular (moldura de celular). */
export type HeroDevice = "desktop" | "mobile";
/** Esquema de fluxo que ocupa o topo do serviço sem case com captura gravada (app/servicos/_components/ServiceDiagram.tsx). */
export type ServiceDiagramKind = "desktop" | "iot" | "arquitetura";
/**
* Rótulos dos botões e links de ação, numa lista só: o contato diz sempre a mesma coisa, e o case também.
* O verde-limão fica só no contato; os demais são contorno ou link de texto.
*/
export const buttonLabels = {
contact: "Falar sobre meu projeto",
case: "Ver case",
site: "Abrir o site",
service: "Ver o serviço",
allCases: "Ver os cases",
allServices: "Ver todos os serviços",
} as const;
export interface ServicePageContent { export interface ServicePageContent {
slug: string; slug: string;
navLabel: string; navLabel: string;
@@ -12,6 +31,7 @@ export interface ServicePageContent {
metaTitle: string; metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */ /** Descrição para busca, com até 155 caracteres. */
metaDescription: string; metaDescription: string;
/** Título da página (h1). Nos serviços que são uma das ofertas da home, é o nome da oferta. */
headline: string; headline: string;
intro: string; intro: string;
answer: string; answer: string;
@@ -21,9 +41,14 @@ export interface ServicePageContent {
examples: string[]; examples: string[];
technicalDetails: string[]; technicalDetails: string[];
stack: string[]; stack: string[];
ctaLabel: string;
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */ /** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
relatedCaseIds: string[]; relatedCaseIds: string[];
/** Capa do topo: o case (um dos relacionados) e o aparelho da captura. Sem ela, o topo mostra o esquema `diagram`. */
heroCase?: { id: string; device: HeroDevice };
/** Esquema de fluxo do topo, para o serviço sem case com captura: nunca leva dado real nem logo de cliente. */
diagram?: ServiceDiagramKind;
/** Data (AAAA-MM-DD) da última mudança no conteúdo da página: vira o `lastmod` do mapa do site. Atualize ao mudar o texto. */
updatedAt: string;
/** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */ /** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */
caseRelevance: Record<string, string>; caseRelevance: Record<string, string>;
/** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */ /** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */
@@ -59,6 +84,10 @@ export interface CaseStudyContent {
facts?: string[]; facts?: string[];
/** O que foi feito, escrito como benefício. */ /** O que foi feito, escrito como benefício. */
deliveries: string[]; deliveries: string[];
/** O que o cliente passou a poder fazer (2 ou 3 itens, sem número), só com o que as entregas já sustentam. Sem itens, a seção não aparece. */
capabilities?: string[];
/** Data (AAAA-MM-DD) da última mudança no conteúdo da página: vira o `lastmod` do mapa do site. Atualize ao mudar o texto. */
updatedAt: string;
/** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */ /** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */
result?: string[]; result?: string[];
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */ /** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */
@@ -77,7 +106,7 @@ export const servicePages: ServicePageContent[] = [
metaTitle: "Criação de sites em Joinville e SC", metaTitle: "Criação de sites em Joinville e SC",
metaDescription: metaDescription:
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.", "Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
headline: "Um site que explica seu negócio.", headline: "Site que traz pedidos pelo WhatsApp",
answer: answer:
"Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.", "Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
intro: intro:
@@ -109,11 +138,10 @@ export const servicePages: ServicePageContent[] = [
"Metadados, URLs canônicas, sitemap e dados estruturados ajudam os buscadores a entender o site.", "Metadados, URLs canônicas, sitemap e dados estruturados ajudam os buscadores a entender o site.",
], ],
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"], stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
ctaLabel: "Conversar sobre meu site", relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"],
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"], heroCase: { id: "lavacao-047", device: "desktop" },
updatedAt: "2026-10-14",
caseRelevance: { caseRelevance: {
"sev-modaintima":
"Um catálogo com busca e filtros que leva o pedido da cliente direto ao WhatsApp da loja.",
"lavacao-047": "lavacao-047":
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.", "Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
"sc-plus": "sc-plus":
@@ -146,7 +174,7 @@ export const servicePages: ServicePageContent[] = [
metaTitle: "Sistemas web e aplicativos sob medida", metaTitle: "Sistemas web e aplicativos sob medida",
metaDescription: metaDescription:
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.", "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
headline: "Sistemas e apps para o seu trabalho.", headline: "Sistema para tirar a operação da planilha",
answer: answer:
"Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.", "Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.",
intro: intro:
@@ -178,8 +206,9 @@ export const servicePages: ServicePageContent[] = [
"Em aplicativos mobile, a tecnologia é escolhida depois de avaliar os dispositivos e os recursos necessários, como câmera, notificações e uso sem internet.", "Em aplicativos mobile, a tecnologia é escolhida depois de avaliar os dispositivos e os recursos necessários, como câmera, notificações e uso sem internet.",
], ],
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"], stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
ctaLabel: "Conversar sobre meu sistema ou app",
relatedCaseIds: ["sev-modaintima", "lavacao-047"], relatedCaseIds: ["sev-modaintima", "lavacao-047"],
heroCase: { id: "sev-modaintima", device: "mobile" },
updatedAt: "2026-10-14",
caseRelevance: { caseRelevance: {
"sev-modaintima": "sev-modaintima":
"Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.", "Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.",
@@ -243,15 +272,14 @@ export const servicePages: ServicePageContent[] = [
"Auditoria de metadados, canônicas, sitemap, dados estruturados e links internos, preservando URLs e requisitos de indexação nas mudanças.", "Auditoria de metadados, canônicas, sitemap, dados estruturados e links internos, preservando URLs e requisitos de indexação nas mudanças.",
], ],
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"], stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
ctaLabel: "Conversar sobre meu projeto Next.js", relatedCaseIds: ["sc-plus", "lavacao-047"],
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"], heroCase: { id: "sc-plus", device: "desktop" },
updatedAt: "2026-10-14",
caseRelevance: { caseRelevance: {
"sev-modaintima":
"Uma loja que construí em Next.js 16, com catálogo, busca, estoque e painel.",
"lavacao-047":
"Um site que construí em Next.js 16, em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
"sc-plus": "sc-plus":
"Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.", "Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.",
"lavacao-047":
"Um site que construí em Next.js 16, em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
}, },
faq: [ faq: [
{ {
@@ -315,11 +343,12 @@ export const servicePages: ServicePageContent[] = [
"Aplicativos desktop", "Aplicativos desktop",
"Integração com o sistema", "Integração com o sistema",
], ],
ctaLabel: "Conversar sobre meu aplicativo desktop",
relatedCaseIds: ["gcc-installer"], relatedCaseIds: ["gcc-installer"],
diagram: "desktop",
updatedAt: "2026-10-14",
caseRelevance: { caseRelevance: {
"gcc-installer": "gcc-installer":
"Um aplicativo para Windows feito por mim, em Python e não em Tauri: lista as versões do compilador, instala e ajusta o PATH em uma única janela.", "Um projeto próprio de aplicativo para Windows: lista as versões do compilador, instala e ajusta o PATH em uma única janela.",
}, },
faq: [ faq: [
{ {
@@ -378,8 +407,9 @@ export const servicePages: ServicePageContent[] = [
"O Twins Lab, protótipo de laboratório, simula um reator e envia o estado dele a um painel a cada dois segundos, por eventos do servidor.", "O Twins Lab, protótipo de laboratório, simula um reator e envia o estado dele a um painel a cada dois segundos, por eventos do servidor.",
], ],
stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"], stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"],
ctaLabel: "Avaliar minha integração",
relatedCaseIds: ["twins-lab"], relatedCaseIds: ["twins-lab"],
diagram: "iot",
updatedAt: "2026-10-14",
caseRelevance: { caseRelevance: {
"twins-lab": "twins-lab":
"Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.", "Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.",
@@ -449,8 +479,9 @@ export const servicePages: ServicePageContent[] = [
"Integrações", "Integrações",
"Sistemas críticos", "Sistemas críticos",
], ],
ctaLabel: "Conversar sobre um diagnóstico",
relatedCaseIds: [], relatedCaseIds: [],
diagram: "arquitetura",
updatedAt: "2026-10-14",
caseRelevance: {}, caseRelevance: {},
experienceNote: experienceNote:
"Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.", "Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
@@ -482,7 +513,7 @@ export const caseStudies: CaseStudyContent[] = [
metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp", metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp",
metaDescription: metaDescription:
"Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.", "Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.",
headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp", headline: "Loja virtual de moda íntima com pedido pelo WhatsApp",
summary: summary:
"Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.", "Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.",
impact: impact:
@@ -499,6 +530,12 @@ export const caseStudies: CaseStudyContent[] = [
"Frete calculado pela distância até o endereço da cliente.", "Frete calculado pela distância até o endereço da cliente.",
"Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.", "Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
], ],
capabilities: [
"A cliente encontra a peça, escolhe o tamanho e envia o pedido pelo WhatsApp.",
"A loja cadastra as peças e acompanha os pedidos em um painel próprio.",
"A loja controla o estoque de cada tamanho e sabe o que ainda tem para vender.",
],
updatedAt: "2026-10-14",
stack: [ stack: [
"Next.js 16", "Next.js 16",
"TypeScript", "TypeScript",
@@ -529,9 +566,9 @@ export const caseStudies: CaseStudyContent[] = [
impact: impact:
"O cliente descobre o preço e pede pelo WhatsApp; o dono atualiza o site sem depender de um desenvolvedor.", "O cliente descobre o preço e pede pelo WhatsApp; o dono atualiza o site sem depender de um desenvolvedor.",
context: context:
"A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.", "A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente ao WhatsApp.",
problem: problem:
"O cliente chega com perguntas diretas: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.", "O cliente chega com perguntas simples: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.",
deliveries: [ deliveries: [
"Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.", "Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.",
"Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.", "Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.",
@@ -541,6 +578,12 @@ export const caseStudies: CaseStudyContent[] = [
"Informações organizadas para o Google e para assistentes de IA entenderem o que a lavação faz e onde fica.", "Informações organizadas para o Google e para assistentes de IA entenderem o que a lavação faz e onde fica.",
"Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.", "Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.",
], ],
capabilities: [
"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.",
"O cliente vê o preço pelo porte do veículo e pede o serviço pelo WhatsApp com a mensagem já pronta.",
"O cliente confere a galeria de trabalhos de cada serviço antes de chamar.",
],
updatedAt: "2026-10-14",
stack: [ stack: [
"Next.js 16", "Next.js 16",
"React 19", "React 19",
@@ -587,6 +630,7 @@ export const caseStudies: CaseStudyContent[] = [
"A camada de acesso à base externa cancela requisições após oito segundos e separa a busca por nome da busca por código.", "A camada de acesso à base externa cancela requisições após oito segundos e separa a busca por nome da busca por código.",
], ],
demoUrl: "https://nutriscan.evandro.dev.br", demoUrl: "https://nutriscan.evandro.dev.br",
updatedAt: "2026-10-14",
}, },
{ {
id: "twins-lab", id: "twins-lab",
@@ -617,6 +661,7 @@ export const caseStudies: CaseStudyContent[] = [
"A resposta usa text/event-stream (SSE), envia estados, dados e erros em intervalos de dois segundos e limita a conexão a dez minutos.", "A resposta usa text/event-stream (SSE), envia estados, dados e erros em intervalos de dois segundos e limita a conexão a dez minutos.",
"O simulador salva dados e estado no MongoDB e notifica os clientes quando a execução ou o cenário mudam.", "O simulador salva dados e estado no MongoDB e notifica os clientes quando a execução ou o cenário mudam.",
], ],
updatedAt: "2026-10-14",
}, },
{ {
id: "reddit-explorer", id: "reddit-explorer",
@@ -647,6 +692,7 @@ export const caseStudies: CaseStudyContent[] = [
"O endpoint de filtro valida as datas e o campo de ordenação (votos ou comentários) e responde com erro 400 quando faltam parâmetros ou eles são inválidos.", "O endpoint de filtro valida as datas e o campo de ordenação (votos ou comentários) e responde com erro 400 quando faltam parâmetros ou eles são inválidos.",
"O Astro roda com saída de servidor e o adaptador Node em modo standalone.", "O Astro roda com saída de servidor e o adaptador Node em modo standalone.",
], ],
updatedAt: "2026-10-14",
}, },
{ {
id: "sc-plus", id: "sc-plus",
@@ -667,11 +713,17 @@ export const caseStudies: CaseStudyContent[] = [
"A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.", "A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.",
deliveries: [ deliveries: [
"Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.", "Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.",
"Contato direto na própria página.", "Contato na própria página.",
"Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.", "Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
"Título, descrição e imagem de compartilhamento próprios, para o link aparecer bem nos buscadores e nas redes.", "Título, descrição e imagem de compartilhamento próprios, para o link aparecer bem nos buscadores e nas redes.",
"Dados estruturados que ajudam o Google a entender de quem é a página.", "Dados estruturados que ajudam o Google a entender de quem é a página.",
], ],
capabilities: [
"Quem chega pela primeira vez vê a instituição, as áreas de atuação e os projetos, e tem o contato na própria página.",
"Quem quer apoiar ou participar encontra o caminho na própria página.",
"O link aparece com título, descrição e imagem próprios nos buscadores e nas redes.",
],
updatedAt: "2026-10-14",
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"], stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
decisions: [ decisions: [
"A página publica título, descrição, endereço canônico e imagem de compartilhamento.", "A página publica título, descrição, endereço canônico e imagem de compartilhamento.",
@@ -708,6 +760,7 @@ export const caseStudies: CaseStudyContent[] = [
"Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE.", "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE.",
"Os 23 testes automatizados cobrem a lógica do núcleo, que não depende da plataforma.", "Os 23 testes automatizados cobrem a lógica do núcleo, que não depende da plataforma.",
], ],
updatedAt: "2026-10-14",
}, },
{ {
id: "as-servicos", id: "as-servicos",
@@ -731,6 +784,12 @@ export const caseStudies: CaseStudyContent[] = [
"A qualidade da cena se ajusta ao aparelho: celulares, conexões mais simples e quem prefere menos movimento recebem uma versão mais leve.", "A qualidade da cena se ajusta ao aparelho: celulares, conexões mais simples e quem prefere menos movimento recebem uma versão mais leve.",
"Um painel para controle de projetos, desenvolvido junto com a primeira página do site.", "Um painel para controle de projetos, desenvolvido junto com a primeira página do site.",
], ],
capabilities: [
"A empresa conta a engenharia física e o software na mesma história, em vez de duas listas de serviços sem relação.",
"Quem visita no celular ou com conexão mais simples recebe uma versão mais leve da cena.",
"A empresa passou a ter um painel para controle de projetos.",
],
updatedAt: "2026-10-14",
stack: ["Next.js 16", "Three.js", "TypeScript"], stack: ["Next.js 16", "Three.js", "TypeScript"],
decisions: [ decisions: [
"A cena é gerada em código, sem arquivo 3D pronto: o motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js.", "A cena é gerada em código, sem arquivo 3D pronto: o motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js.",
@@ -756,6 +815,111 @@ export const labCases = caseStudies.filter(
(caseStudy) => !isClientCase(caseStudy), (caseStudy) => !isClientCase(caseStudy),
); );
/**
* Ordem dos clientes na lista de /cases e no mosaico de /servicos: a SEV Moda Íntima, que já abre a página de sistemas
* e é a prova da oferta de sistema, vem por último e fica fora das três capas do mosaico. Cliente fora desta lista vai para o fim.
*/
const clientListingOrder = [
"lavacao-047",
"sc-plus",
"as-servicos",
"sev-modaintima",
];
function listingRank({ id }: { id: string }) {
const rank = clientListingOrder.indexOf(id);
return rank === -1 ? clientListingOrder.length : rank;
}
export const clientCasesForListing = [...clientCases].sort(
(a, b) => listingRank(a) - listingRank(b),
);
/** Data (AAAA-MM-DD) da última mudança no conteúdo de cada página fixa: vira o `lastmod` do mapa do site. */
export const pageUpdatedAt = {
home: "2026-10-14",
servicos: "2026-10-14",
cases: "2026-10-14",
ia: "2026-10-14",
curriculo: "2026-10-14",
// a própria página informa a vigência (13 de setembro de 2026)
privacidade: "2026-09-13",
} as const;
export interface ServiceOffer {
id: "site" | "sistema" | "revisao";
/** Nome da oferta, o mesmo da home. */
name: string;
/** Uma frase sobre a oferta (descrição dos dados estruturados). */
summary: string;
/** Página que explica a oferta. */
href: string;
/** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. */
deliverables: string[];
/** Onde ver a oferta funcionando: o texto que abre a linha, os cases (links) e uma observação opcional. */
proof: { label: string; caseIds: string[]; note?: string };
}
/**
* As três ofertas da home, na ordem do H1, com o mesmo nome. Cada texto só diz o que os cases citados, a experiência
* profissional ou a página /ia já sustentam; sem prazo nem preço.
*/
export const serviceOffers: ServiceOffer[] = [
{
id: "site",
name: "Site que traz pedidos pelo WhatsApp",
summary:
"Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho.",
href: "/servicos/criacao-de-sites",
deliverables: [
"Tabela de preços ou catálogo, para o cliente ver o valor antes de chamar.",
"Botão de WhatsApp com a mensagem pronta, para o pedido chegar com o serviço escolhido.",
"Painel para você mudar preços, textos e fotos sem pedir a ninguém.",
"Leitura boa no celular e informações organizadas para o Google.",
],
proof: {
label: "Veja funcionando",
caseIds: ["lavacao-047", "sev-modaintima"],
},
},
{
id: "sistema",
name: "Sistema para tirar a operação da planilha",
summary:
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe.",
href: "/servicos/engenharia-de-software",
deliverables: [
"Catálogo e cadastro de produtos em um painel próprio.",
"Pedidos e estoque acompanhados em um só lugar, no lugar de planilhas e mensagens.",
"Acesso por perfil: cada pessoa vê só o que precisa.",
"Uma primeira versão com as funções prioritárias, ampliada em etapas.",
],
proof: {
label: "Veja funcionando",
caseIds: ["sev-modaintima"],
note: "Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile para rotinas internas, APIs e painéis de operação. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
},
},
{
id: "revisao",
name: "Revisão de sistema que já existe, inclusive feito com IA",
summary:
"Avaliação do que já existe, teste das falhas de acesso a dados e separação do que dá para aproveitar do que é melhor refazer.",
href: "/ia",
deliverables: [
"As falhas de acesso a dados: quem consegue ver ou alterar o dado de quem.",
"O que dá para aproveitar do que já está pronto.",
"O que é melhor refazer, com o motivo de cada parte.",
"Um orçamento por escrito para pôr o sistema em produção.",
],
proof: {
label: "Veja como funciona",
caseIds: [],
note: "A página da revisão mostra o que você recebe, três episódios públicos com plataformas de apps feitos com IA e os cuidados com dados pessoais (LGPD).",
},
},
];
export const serviceHubLinks = servicePages.map((service) => ({ export const serviceHubLinks = servicePages.map((service) => ({
href: `/servicos/${service.slug}`, href: `/servicos/${service.slug}`,
label: service.navLabel, label: service.navLabel,