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