feat(servicos): topo, entregáveis e cases por serviço, assunto próprio no WhatsApp e títulos curtos
This commit is contained in:
1 parent
cf360041bd
commit
2fd8b063d9
33 files changed
+1578
-300
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { ImageResponse } from "next/og";
|
||||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||||
|
|
||||||
export const alt = "Revisão de app feito com IA";
|
export const alt = "Revisão de sistema e de app feito com IA";
|
||||||
export const size = OG_SIZE;
|
export const size = OG_SIZE;
|
||||||
export const contentType = OG_CONTENT_TYPE;
|
export const contentType = OG_CONTENT_TYPE;
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export default function Image() {
|
|||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OgCard
|
<OgCard
|
||||||
eyebrow="IA no desenvolvimento"
|
eyebrow="IA no desenvolvimento"
|
||||||
title="Revisão de app feito com IA"
|
title="Revisão de sistema e de app feito com IA"
|
||||||
description="Falhas de acesso a dados, o que aproveitar, o que refazer e um orçamento para pôr em produção."
|
description="Falhas de acesso a dados, o que aproveitar, o que refazer e um orçamento para pôr em produção."
|
||||||
/>,
|
/>,
|
||||||
OG_SIZE,
|
OG_SIZE,
|
||||||
|
|||||||
+26
-6
@@ -1,18 +1,24 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { offerLabels } from "@/lib/offer-labels";
|
||||||
import AiPage, { metadata } from "./page";
|
import AiPage, { metadata } from "./page";
|
||||||
|
|
||||||
it("uses a short search title: with the layout suffix it fits in 44 characters", () => {
|
it("uses a short search title: up to 44 characters, and 60 with the layout suffix", () => {
|
||||||
// o template de app/layout.tsx acrescenta " | Evandro.dev.br"
|
// o template de app/layout.tsx acrescenta " | Evandro.dev.br"
|
||||||
const suffix = " | Evandro.dev.br";
|
const suffix = " | Evandro.dev.br";
|
||||||
expect(metadata.title).toBe("Revisão de app feito com IA");
|
expect(metadata.title).toBe("Revisão de sistema e de app feito com IA");
|
||||||
expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(44);
|
expect(String(metadata.title).length).toBeLessThanOrEqual(44);
|
||||||
|
expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(60);
|
||||||
expect(metadata).toMatchObject({
|
expect(metadata).toMatchObject({
|
||||||
description: expect.any(String),
|
description: expect.any(String),
|
||||||
alternates: { canonical: "https://evandro.dev.br/ia" },
|
alternates: { canonical: "https://evandro.dev.br/ia" },
|
||||||
});
|
});
|
||||||
// como nas outras páginas, o título da prévia de compartilhamento é o mesmo da busca, sem o sufixo
|
// como nas outras páginas, o título da prévia de compartilhamento é o mesmo da busca, sem o sufixo
|
||||||
expect(metadata.openGraph?.title).toBe("Revisão de app feito com IA");
|
expect(metadata.openGraph?.title).toBe(
|
||||||
expect(metadata.twitter?.title).toBe("Revisão de app feito com IA");
|
"Revisão de sistema e de app feito com IA",
|
||||||
|
);
|
||||||
|
expect(metadata.twitter?.title).toBe(
|
||||||
|
"Revisão de sistema e de app feito com IA",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses no lime outside the contact actions (the quote on the paper background is ink)", () => {
|
it("uses no lime outside the contact actions (the quote on the paper background is ink)", () => {
|
||||||
@@ -75,7 +81,7 @@ it("has breadcrumb navigation back to the home", () => {
|
|||||||
within(breadcrumb).getByRole("link", { name: "Início" }),
|
within(breadcrumb).getByRole("link", { name: "Início" }),
|
||||||
).toHaveAttribute("href", "/");
|
).toHaveAttribute("href", "/");
|
||||||
expect(
|
expect(
|
||||||
within(breadcrumb).getByText("Revisão de app feito com IA"),
|
within(breadcrumb).getByText(offerLabels.revisao.name),
|
||||||
).toHaveAttribute("aria-current", "page");
|
).toHaveAttribute("aria-current", "page");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,3 +238,17 @@ it("keeps the incident news collapsed in a <details>, closed by default", () =>
|
|||||||
.closest("details"),
|
.closest("details"),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names the review like the offer on the home: the trail and the structured data use the offer name, not the search title", () => {
|
||||||
|
const { container } = render(<AiPage />);
|
||||||
|
|
||||||
|
const script = container.querySelector('script[type="application/ld+json"]');
|
||||||
|
const data = JSON.parse(script?.textContent ?? "{}");
|
||||||
|
const breadcrumb = data["@graph"].find(
|
||||||
|
(node: { "@type": string }) => node["@type"] === "BreadcrumbList",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
breadcrumb.itemListElement.map(({ name }: { name: string }) => name),
|
||||||
|
).toEqual(["Início", offerLabels.revisao.name]);
|
||||||
|
expect(offerLabels.revisao.name).toBe("Revisão de sistema que já existe");
|
||||||
|
});
|
||||||
+8
-4
@@ -8,13 +8,17 @@ import {
|
|||||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
|
import { offerLabels } from "@/lib/offer-labels";
|
||||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||||
import { siteUrl } from "@/lib/site-content";
|
import { siteUrl } from "@/lib/site-content";
|
||||||
import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp";
|
import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
|
||||||
const pageUrl = `${siteUrl}/ia`;
|
const pageUrl = `${siteUrl}/ia`;
|
||||||
/** Curto de propósito: o template de app/layout.tsx acrescenta " | Evandro.dev.br" e o conjunto cabe em 44 caracteres. */
|
/**
|
||||||
const pageTitle = "Revisão de app feito com IA";
|
* Título para busca: até 44 caracteres, e 60 com o " | Evandro.dev.br" que o template de app/layout.tsx acrescenta. Cobre a oferta
|
||||||
|
* (revisão de sistema) e a busca por app feito com IA. O nome da oferta, o mesmo da home, é o da trilha e dos dados estruturados.
|
||||||
|
*/
|
||||||
|
const pageTitle = "Revisão de sistema e de app feito com IA";
|
||||||
const pageDescription =
|
const pageDescription =
|
||||||
"Fez um app com Lovable, Bolt ou Replit? Eu reviso as falhas de acesso a dados, separo o que aproveitar do que refazer e faço o orçamento para a produção.";
|
"Fez um app com Lovable, Bolt ou Replit? Eu reviso as falhas de acesso a dados, separo o que aproveitar do que refazer e faço o orçamento para a produção.";
|
||||||
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp (a mesma do botão flutuante em /ia). */
|
/** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp (a mesma do botão flutuante em /ia). */
|
||||||
@@ -167,7 +171,7 @@ export default function AiPage() {
|
|||||||
"@graph": [
|
"@graph": [
|
||||||
buildBreadcrumbList(pageUrl, [
|
buildBreadcrumbList(pageUrl, [
|
||||||
["Início", siteUrl],
|
["Início", siteUrl],
|
||||||
["Revisão de app feito com IA", pageUrl],
|
[offerLabels.revisao.name, pageUrl],
|
||||||
] as const),
|
] as const),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -181,7 +185,7 @@ export default function AiPage() {
|
|||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: "Início", href: "/" },
|
{ label: "Início", href: "/" },
|
||||||
{ label: "Revisão de app feito com IA", href: "/ia" },
|
{ label: offerLabels.revisao.name, href: "/ia" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-sp-12">
|
<div className="grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-sp-12">
|
||||||
|
|||||||
+31
-15
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { Geist_Mono, Space_Grotesk } from "next/font/google";
|
||||||
import { join } from "node:path";
|
import localFont from "next/font/local";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { caseStudies, serviceHubLinks, servicePages } from "@/lib/site-content";
|
import { caseStudies, serviceHubLinks, servicePages } from "@/lib/site-content";
|
||||||
import RootLayout, { metadata } from "./layout";
|
import RootLayout, { metadata } from "./layout";
|
||||||
@@ -14,9 +14,10 @@ const mockFloatingProps = jest.fn();
|
|||||||
const mockFooterProps = jest.fn();
|
const mockFooterProps = jest.fn();
|
||||||
|
|
||||||
jest.mock("next/font/google", () => ({
|
jest.mock("next/font/google", () => ({
|
||||||
Geist_Mono: () => ({ variable: "font-mono-var" }),
|
Geist_Mono: jest.fn(() => ({ variable: "font-mono-var" })),
|
||||||
Space_Grotesk: () => ({ variable: "font-display-var" }),
|
Space_Grotesk: jest.fn(() => ({ variable: "font-display-var" })),
|
||||||
}));
|
}));
|
||||||
|
jest.mock("next/font/local", () => ({ __esModule: true, default: jest.fn() }));
|
||||||
jest.mock("./globals.css", () => ({}));
|
jest.mock("./globals.css", () => ({}));
|
||||||
jest.mock("./components/cinematic/CinematicOverlays", () => ({
|
jest.mock("./components/cinematic/CinematicOverlays", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
@@ -47,6 +48,12 @@ jest.mock("./components/FloatingWhatsApp", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// as fontes são pedidas ao carregar o layout (uma vez, na importação): lê-se o que foi pedido antes de qualquer
|
||||||
|
// jest.clearAllMocks
|
||||||
|
const monoFontOptions = jest.mocked(Geist_Mono).mock.calls[0]?.[0];
|
||||||
|
const displayFontOptions = jest.mocked(Space_Grotesk).mock.calls[0]?.[0];
|
||||||
|
const localFontCalls = jest.mocked(localFont).mock.calls.length;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -119,12 +126,12 @@ describe("what the layout mounts", () => {
|
|||||||
expect(html.match(/id="site-footer"/g)).toHaveLength(1);
|
expect(html.match(/id="site-footer"/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives the header and the button the service and case names, and only names", () => {
|
it("gives the header and the button the whatsappSubject of each service and the case names, and only those", () => {
|
||||||
markup();
|
markup();
|
||||||
|
|
||||||
const expected = {
|
const expected = {
|
||||||
services: Object.fromEntries(
|
services: Object.fromEntries(
|
||||||
servicePages.map((service) => [service.slug, service.navLabel]),
|
servicePages.map((service) => [service.slug, service.whatsappSubject]),
|
||||||
),
|
),
|
||||||
cases: Object.fromEntries(
|
cases: Object.fromEntries(
|
||||||
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
|
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
|
||||||
@@ -135,9 +142,19 @@ describe("what the layout mounts", () => {
|
|||||||
whatsAppContexts: expected,
|
whatsAppContexts: expected,
|
||||||
});
|
});
|
||||||
expect(Object.keys(expected.services).length).toBeGreaterThan(0);
|
expect(Object.keys(expected.services).length).toBeGreaterThan(0);
|
||||||
|
// a mensagem lê "Quero conversar sobre {assunto}.": nenhum assunto de serviço começa em maiúscula
|
||||||
|
for (const subject of Object.values(expected.services)) {
|
||||||
|
expect(subject).toMatch(/^[a-zà-ú]/);
|
||||||
|
}
|
||||||
expect(Object.keys(expected.cases).length).toBeGreaterThan(0);
|
expect(Object.keys(expected.cases).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the smooth anchors but opens each new page at the top: the html carries data-scroll-behavior, which makes Next turn the smooth scroll off only during the route change", () => {
|
||||||
|
// o atributo é o que o Next lê para desligar a rolagem suave só durante a troca de rota (o e2e navigation.spec.ts
|
||||||
|
// confere no navegador que a página nova abre em scrollY 0, sem deslizar)
|
||||||
|
expect(markup()).toMatch(/<html[^>]* data-scroll-behavior="smooth"/);
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the JavaScript-only parts of the page when scripts are off", () => {
|
it("hides the JavaScript-only parts of the page when scripts are off", () => {
|
||||||
expect(markup()).toContain(".js-only{display:none!important}");
|
expect(markup()).toContain(".js-only{display:none!important}");
|
||||||
});
|
});
|
||||||
@@ -145,18 +162,17 @@ describe("what the layout mounts", () => {
|
|||||||
|
|
||||||
describe("fonts", () => {
|
describe("fonts", () => {
|
||||||
it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => {
|
it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => {
|
||||||
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
|
expect(monoFontOptions).toEqual(
|
||||||
|
expect.objectContaining({ preload: false }),
|
||||||
const mono = source.match(/Geist_Mono\(\{[^}]*\}\)/)?.[0] ?? "";
|
);
|
||||||
expect(mono).toMatch(/preload:\s*false/);
|
expect(displayFontOptions).toBeDefined();
|
||||||
const display = source.match(/Space_Grotesk\(\{[^}]*\}\)/)?.[0] ?? "";
|
expect(displayFontOptions).not.toEqual(
|
||||||
expect(display).not.toMatch(/preload:\s*false/);
|
expect.objectContaining({ preload: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no longer loads the unused Monument Extended font", () => {
|
it("no longer loads the unused Monument Extended font", () => {
|
||||||
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
|
expect(localFontCalls).toBe(0);
|
||||||
|
|
||||||
expect(source).not.toMatch(/Monument|next\/font\/local/i);
|
|
||||||
expect(markupClassName()).not.toMatch(/monument/i);
|
expect(markupClassName()).not.toMatch(/monument/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -28,10 +28,14 @@ const defaultTitle = `Sites e sistemas em Joinville e SC${titleSuffix}`;
|
|||||||
const seoDescription =
|
const seoDescription =
|
||||||
"Sites com pedido pelo WhatsApp e sistemas que tiram sua operação da planilha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil.";
|
"Sites com pedido pelo WhatsApp e sistemas que tiram sua operação da planilha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil.";
|
||||||
|
|
||||||
/** Para o WhatsApp falar do serviço ou do case da página aberta: só nomes, sem o conteúdo pesado das páginas. */
|
/**
|
||||||
|
* Para o WhatsApp falar do serviço ou do case da página aberta: só os assuntos e nomes, sem o conteúdo pesado das
|
||||||
|
* páginas. O serviço entra pelo `whatsappSubject` (uma frase em minúsculas, que cabe em "Quero conversar sobre
|
||||||
|
* {assunto}."), e não pelo nome: o nome daria "sobre Sites profissionais.".
|
||||||
|
*/
|
||||||
const whatsAppContexts: WhatsAppContexts = {
|
const whatsAppContexts: WhatsAppContexts = {
|
||||||
services: Object.fromEntries(
|
services: Object.fromEntries(
|
||||||
servicePages.map((service) => [service.slug, service.navLabel]),
|
servicePages.map((service) => [service.slug, service.whatsappSubject]),
|
||||||
),
|
),
|
||||||
cases: Object.fromEntries(
|
cases: Object.fromEntries(
|
||||||
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
|
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
|
||||||
@@ -130,8 +134,11 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// data-scroll-behavior: com `scroll-behavior: smooth` no CSS global, o Next desliga a suavização só durante a troca
|
||||||
|
// de página (a página nova abre no topo, sem deslizar) e a restaura depois; as âncoras da mesma página seguem suaves.
|
||||||
<html
|
<html
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
className={`${spaceGrotesk.variable} ${geistMono.variable}`}
|
className={`${spaceGrotesk.variable} ${geistMono.variable}`}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { metadata as layoutMetadata } from "./layout";
|
|||||||
import { metadata as notFoundMetadata } from "./not-found";
|
import { metadata as notFoundMetadata } from "./not-found";
|
||||||
import sitemap from "./sitemap";
|
import sitemap from "./sitemap";
|
||||||
|
|
||||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
|
||||||
// o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe
|
// o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe
|
||||||
jest.mock("next/font/google", () => ({
|
jest.mock("next/font/google", () => ({
|
||||||
Geist_Mono: () => ({ variable: "font-mono-var" }),
|
Geist_Mono: () => ({ variable: "font-mono-var" }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||||
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
import { getServicePage } from "@/lib/site-content";
|
import { getServicePage } from "@/lib/site-content";
|
||||||
@@ -15,12 +16,9 @@ import ServicePage from "./page";
|
|||||||
* Quando a capa vale, a legenda dela cita o case e o case não ganha cartão em "Trabalhos parecidos".
|
* 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", () => ({
|
beforeEach(() => {
|
||||||
notFound: jest.fn(() => {
|
setNavigation({ pathname: "/servicos" });
|
||||||
throw new Error("NEXT_NOT_FOUND");
|
});
|
||||||
}),
|
|
||||||
usePathname: () => "/servicos",
|
|
||||||
}));
|
|
||||||
jest.mock("@/app/cases/_components/case-media", () => ({
|
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||||
...jest.requireActual("@/app/cases/_components/case-media"),
|
...jest.requireActual("@/app/cases/_components/case-media"),
|
||||||
getCaseMedia: jest.fn(),
|
getCaseMedia: jest.fn(),
|
||||||
@@ -74,7 +72,8 @@ describe("ServicePage: escolha da capa do topo", () => {
|
|||||||
expect(view.compact).toBe(false);
|
expect(view.compact).toBe(false);
|
||||||
expect(view.caption).toHaveTextContent("Lavação 047");
|
expect(view.caption).toHaveTextContent("Lavação 047");
|
||||||
expect(view.cards).not.toContain("Lavação 047");
|
expect(view.cards).not.toContain("Lavação 047");
|
||||||
expect(view.cards).toEqual(["SC Plus Aquicultura", "AS Serviços"]);
|
// a SEV é o segundo trabalho; SC Plus e AS Serviços ficam no bloco menor, fora de "Onde já apliquei isso"
|
||||||
|
expect(view.cards).toEqual(["SEV Moda Íntima"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: mobile cover with its capture on disk -> phone frame in the hero, no browser frame", async () => {
|
it("path: mobile cover with its capture on disk -> phone frame in the hero, no browser frame", async () => {
|
||||||
@@ -98,11 +97,7 @@ describe("ServicePage: escolha da capa do topo", () => {
|
|||||||
expect(view.twoColumns).toBe(false);
|
expect(view.twoColumns).toBe(false);
|
||||||
expect(view.compact).toBe(true);
|
expect(view.compact).toBe(true);
|
||||||
expect(screen.queryByRole("img")).toBeNull();
|
expect(screen.queryByRole("img")).toBeNull();
|
||||||
expect(view.cards).toEqual([
|
expect(view.cards).toEqual(["Lavação 047", "SEV Moda Íntima"]);
|
||||||
"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 () => {
|
it("path: only the OTHER device was captured (mobile cover, desktop capture) -> no cover, the check follows the device the service asks for", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
import { getServicePage } from "@/lib/site-content";
|
import { getServicePage } from "@/lib/site-content";
|
||||||
import ServicePage, { generateMetadata } from "./page";
|
import ServicePage, { generateMetadata } from "./page";
|
||||||
|
|
||||||
@@ -8,13 +9,9 @@ import ServicePage, { generateMetadata } from "./page";
|
|||||||
* Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias.
|
* Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
|
beforeEach(() => {
|
||||||
jest.mock("next/navigation", () => ({
|
setNavigation({ pathname: "/servicos" });
|
||||||
notFound: jest.fn(() => {
|
});
|
||||||
throw new Error("NEXT_NOT_FOUND");
|
|
||||||
}),
|
|
||||||
usePathname: () => "/servicos",
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.mocked(notFound).mockClear();
|
jest.mocked(notFound).mockClear();
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import { getServiceOffer, getServicePage } from "@/lib/site-content";
|
||||||
|
import ServicePage from "./page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caminhos-base de ServicePage (CC 4, contado à mão: serviço que não existe | bloco menor `alsoCases` | oferta com
|
||||||
|
* "O que você recebe") nas combinações do bloco menor e da oferta, que page.test.tsx só mostra juntas ou separadas com
|
||||||
|
* dados reais:
|
||||||
|
* - nem bloco menor nem oferta (a base: tauri-rust);
|
||||||
|
* - bloco menor SEM oferta (nenhum serviço real é assim: montado trocando `alsoCases` de consultoria-nextjs);
|
||||||
|
* - bloco menor E oferta (criacao-de-sites): a ordem na página é cases, bloco menor, "O que você recebe".
|
||||||
|
* O slug desconhecido está em page.paths.test.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/servicos" });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderService(slug: string) {
|
||||||
|
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
|
||||||
|
return render(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALSO_TITLE = "Também faço sites institucionais";
|
||||||
|
const ALSO_HEADING = { level: 2, name: ALSO_TITLE } as const;
|
||||||
|
const DELIVERABLES_HEADING = {
|
||||||
|
level: 2,
|
||||||
|
name: "O que você recebe",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("ServicePage: bloco menor e oferta", () => {
|
||||||
|
it("path: no smaller block and no offer -> neither 'Também faço' nor 'O que você recebe', and the rest of the page stays", async () => {
|
||||||
|
expect(getServicePage("tauri-rust")?.alsoCases).toBeUndefined();
|
||||||
|
expect(getServiceOffer("tauri-rust")).toBeUndefined();
|
||||||
|
|
||||||
|
await renderService("tauri-rust");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("heading", ALSO_HEADING)).toBeNull();
|
||||||
|
expect(screen.queryByRole("heading", DELIVERABLES_HEADING)).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Como trabalhamos" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a smaller block on a service that is not an offer -> the block shows, 'O que você recebe' does not", async () => {
|
||||||
|
const service = getServicePage("consultoria-nextjs");
|
||||||
|
if (!service) throw new Error("Missing service consultoria-nextjs");
|
||||||
|
expect(getServiceOffer(service.slug)).toBeUndefined();
|
||||||
|
const original = service.alsoCases;
|
||||||
|
service.alsoCases = { title: "Também faço isto", caseIds: ["sc-plus"] };
|
||||||
|
try {
|
||||||
|
await renderService(service.slug);
|
||||||
|
|
||||||
|
const block = screen
|
||||||
|
.getByRole("heading", { level: 2, name: "Também faço isto" })
|
||||||
|
.closest("section") as HTMLElement;
|
||||||
|
expect(block).toHaveTextContent("SC Plus");
|
||||||
|
expect(screen.queryByRole("heading", DELIVERABLES_HEADING)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
service.alsoCases = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a smaller block on an offer -> the cases come first, then the block, then 'O que você recebe'", async () => {
|
||||||
|
await renderService("criacao-de-sites");
|
||||||
|
|
||||||
|
const cases = document.getElementById("casos-relacionados");
|
||||||
|
const also = screen
|
||||||
|
.getByRole("heading", ALSO_HEADING)
|
||||||
|
.closest("section") as HTMLElement;
|
||||||
|
const deliverables = screen
|
||||||
|
.getByRole("heading", DELIVERABLES_HEADING)
|
||||||
|
.closest("section") as HTMLElement;
|
||||||
|
expect(cases).not.toBeNull();
|
||||||
|
const follows = Node.DOCUMENT_POSITION_FOLLOWING;
|
||||||
|
expect(
|
||||||
|
(cases as HTMLElement).compareDocumentPosition(also) & follows,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(also.compareDocumentPosition(deliverables) & follows).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
import { SEO_IDS } from "@/lib/seo";
|
import { SEO_IDS } from "@/lib/seo";
|
||||||
import {
|
import {
|
||||||
buttonLabels,
|
buttonLabels,
|
||||||
@@ -14,11 +15,9 @@ import {
|
|||||||
import ServicesIndexPage from "../page";
|
import ServicesIndexPage from "../page";
|
||||||
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
|
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
|
||||||
|
|
||||||
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
|
beforeEach(() => {
|
||||||
jest.mock("next/navigation", () => ({
|
setNavigation({ pathname: "/servicos" });
|
||||||
notFound: jest.fn(),
|
});
|
||||||
usePathname: () => "/servicos",
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function renderService(slug: string) {
|
async function renderService(slug: string) {
|
||||||
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
|
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
|
||||||
@@ -117,7 +116,13 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
|||||||
for (const cta of primaryCtas) {
|
for (const cta of primaryCtas) {
|
||||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||||
expect(destination.origin).toBe("https://wa.me");
|
expect(destination.origin).toBe("https://wa.me");
|
||||||
expect(destination.searchParams.get("text")).toContain(service.navLabel);
|
// o assunto da mensagem é o `whatsappSubject` do serviço, em minúsculas, e não o nome com maiúscula
|
||||||
|
expect(destination.searchParams.get("text")).toContain(
|
||||||
|
`Quero conversar sobre ${service.whatsappSubject}.`,
|
||||||
|
);
|
||||||
|
expect(destination.searchParams.get("text")).toContain(
|
||||||
|
"uma consultoria em Next.js",
|
||||||
|
);
|
||||||
expect(cta).toHaveAttribute("target", "_blank");
|
expect(cta).toHaveAttribute("target", "_blank");
|
||||||
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||||
}
|
}
|
||||||
@@ -236,9 +241,13 @@ it("fills the hub hero with a mosaic of three client posters, never opening with
|
|||||||
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
|
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
|
||||||
const posters = Array.from(hero?.querySelectorAll("img") ?? []);
|
const posters = Array.from(hero?.querySelectorAll("img") ?? []);
|
||||||
expect(posters).toHaveLength(3);
|
expect(posters).toHaveLength(3);
|
||||||
|
// a ordem é a de /cases (a vitrine), sem a SEV, que lidera a lista mas não o mosaico
|
||||||
|
const mosaicCases = clientCasesForListing.filter(
|
||||||
|
({ id }) => id !== "sev-modaintima",
|
||||||
|
);
|
||||||
for (const [index, poster] of posters.entries()) {
|
for (const [index, poster] of posters.entries()) {
|
||||||
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
|
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
|
||||||
`/videos/${clientCasesForListing[index].id}/desktop.webp`,
|
`/videos/${mosaicCases[index].id}/desktop.webp`,
|
||||||
);
|
);
|
||||||
expect(poster).toHaveAttribute("alt", "");
|
expect(poster).toHaveAttribute("alt", "");
|
||||||
}
|
}
|
||||||
@@ -406,19 +415,33 @@ it("shows the client cases first, right after the hero, with the first cover in
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
// o case da capa (Lavação 047) é citado pela legenda dela; os cartões trazem os demais, com os de cliente à frente
|
// o case da capa (Lavação 047) é citado pela legenda dela; "Onde já apliquei isso" traz a SEV como segundo trabalho
|
||||||
const caseLinks = caseCardLinks(container);
|
const caseLinks = caseCardLinks(container);
|
||||||
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual(
|
|
||||||
getRelatedCases(siteService.relatedCaseIds)
|
|
||||||
.filter(({ id }) => id !== siteService.heroCase?.id)
|
|
||||||
.map(({ id }) => `/cases/${id}`),
|
|
||||||
);
|
|
||||||
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
|
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
|
||||||
"/cases/sc-plus",
|
"/cases/sev-modaintima",
|
||||||
"/cases/as-servicos",
|
|
||||||
]);
|
]);
|
||||||
|
// SC Plus e AS Serviços saem dali e vão para o bloco menor "Também faço sites institucionais"
|
||||||
|
const alsoHeading = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Também faço sites institucionais",
|
||||||
|
});
|
||||||
|
expect(isBefore(casesHeading, alsoHeading)).toBe(true);
|
||||||
|
const alsoBlock = alsoHeading.closest("section") as HTMLElement;
|
||||||
|
expect(
|
||||||
|
within(alsoBlock)
|
||||||
|
.getAllByRole("link")
|
||||||
|
.map((link) => link.getAttribute("href")),
|
||||||
|
).toEqual(["/cases/sc-plus", "/cases/as-servicos"]);
|
||||||
|
expect(
|
||||||
|
within(alsoBlock).getByText(siteService.caseRelevance["sc-plus"]),
|
||||||
|
).toBeVisible();
|
||||||
|
// sem capa no bloco menor
|
||||||
|
expect(alsoBlock.querySelector("img")).toBeNull();
|
||||||
|
expect(
|
||||||
|
getRelatedCases(siteService.relatedCaseIds).map(({ id }) => id),
|
||||||
|
).toEqual(["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"]);
|
||||||
|
|
||||||
// pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde, e não a SEV
|
// pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
const heroFrame = hero?.querySelector(".ds-bframe");
|
const heroFrame = hero?.querySelector(".ds-bframe");
|
||||||
expect(heroFrame).not.toBeNull();
|
expect(heroFrame).not.toBeNull();
|
||||||
@@ -427,7 +450,11 @@ it("shows the client cases first, right after the hero, with the first cover in
|
|||||||
"Captura de tela do site Lavação 047",
|
"Captura de tela do site Lavação 047",
|
||||||
);
|
);
|
||||||
expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull();
|
expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull();
|
||||||
expect(container.textContent).not.toMatch(/SEV Moda Íntima/);
|
// a SEV tem cartão (segundo trabalho), mas não vira a capa do topo
|
||||||
|
expect(hero?.textContent).not.toMatch(/SEV Moda Íntima/);
|
||||||
|
expect(caseCardLinks(container)[0].closest("article")).toHaveTextContent(
|
||||||
|
siteService.caseRelevance["sev-modaintima"],
|
||||||
|
);
|
||||||
// a legenda da capa cita o case com a frase de relação e o botão "Ver case"
|
// a legenda da capa cita o case com a frase de relação e o botão "Ver case"
|
||||||
const caption = hero?.querySelector("figcaption");
|
const caption = hero?.querySelector("figcaption");
|
||||||
expect(caption).toHaveTextContent("Lavação 047");
|
expect(caption).toHaveTextContent("Lavação 047");
|
||||||
@@ -437,7 +464,7 @@ it("shows the client cases first, right after the hero, with the first cover in
|
|||||||
name: "Ver case Lavação 047",
|
name: "Ver case Lavação 047",
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute("href", "/cases/lavacao-047");
|
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||||
// a mesma capa não aparece duas vezes: o case de destaque não ganha cartão, e os outros mantêm a capa deles
|
// a mesma capa não aparece duas vezes: o case de destaque não ganha cartão; a SEV mantém a dela e os do bloco menor não têm capa
|
||||||
expect(
|
expect(
|
||||||
container.querySelectorAll(
|
container.querySelectorAll(
|
||||||
"img[alt='Captura de tela do site Lavação 047']",
|
"img[alt='Captura de tela do site Lavação 047']",
|
||||||
@@ -447,7 +474,150 @@ it("shows the client cases first, right after the hero, with the first cover in
|
|||||||
container.querySelectorAll(
|
container.querySelectorAll(
|
||||||
"img[alt='Captura de tela do site SC Plus Aquicultura']",
|
"img[alt='Captura de tela do site SC Plus Aquicultura']",
|
||||||
),
|
),
|
||||||
).toHaveLength(1);
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repeats on the two offer pages the 'O que você recebe' and the 'Primeiro passo' of the offer, from the same source as the home", async () => {
|
||||||
|
for (const [slug, offer] of [
|
||||||
|
["criacao-de-sites", serviceOffers[0]],
|
||||||
|
["engenharia-de-software", serviceOffers[1]],
|
||||||
|
] as const) {
|
||||||
|
const { container, unmount } = await renderService(slug);
|
||||||
|
|
||||||
|
const receives = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "O que você recebe",
|
||||||
|
});
|
||||||
|
const block = receives.closest("section") as HTMLElement;
|
||||||
|
expect(
|
||||||
|
within(block)
|
||||||
|
.getAllByRole("listitem")
|
||||||
|
.map((item) => item.textContent),
|
||||||
|
).toEqual(offer.deliverables);
|
||||||
|
const firstStep = within(block).getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Primeiro passo",
|
||||||
|
});
|
||||||
|
expect(isBefore(receives, firstStep)).toBe(true);
|
||||||
|
expect(within(block).getByText(offer.firstStep)).toBeVisible();
|
||||||
|
// vem depois da prova e antes de "Para quem faz sentido"
|
||||||
|
expect(
|
||||||
|
isBefore(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Onde já apliquei isso",
|
||||||
|
}),
|
||||||
|
receives,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isBefore(
|
||||||
|
receives,
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Para quem faz sentido",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
// o bloco não traz botão de contato próprio: o do topo e o do fim bastam
|
||||||
|
expect(within(block).queryByRole("link")).toBeNull();
|
||||||
|
expect(container.textContent).not.toMatch(/\d+\s?dias|R\$/);
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single list of deliverables per page: the offer pages drop 'O que podemos entregar', the others keep it", async () => {
|
||||||
|
for (const slug of ["criacao-de-sites", "engenharia-de-software"]) {
|
||||||
|
const { unmount } = await renderService(slug);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "O que podemos entregar" }),
|
||||||
|
).toBeNull();
|
||||||
|
// "Para quem faz sentido" continua, sozinho
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
for (const slug of [
|
||||||
|
"consultoria-nextjs",
|
||||||
|
"tauri-rust",
|
||||||
|
"integracao-hardware-software-iot",
|
||||||
|
"arquitetura-de-software",
|
||||||
|
]) {
|
||||||
|
const { unmount } = await renderService(slug);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 2, name: "O que podemos entregar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves 'O que você recebe' and 'Primeiro passo' out of the services that are not an offer", async () => {
|
||||||
|
for (const slug of [
|
||||||
|
"consultoria-nextjs",
|
||||||
|
"tauri-rust",
|
||||||
|
"integracao-hardware-software-iot",
|
||||||
|
"arquitetura-de-software",
|
||||||
|
]) {
|
||||||
|
const { unmount } = await renderService(slug);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "O que você recebe" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Primeiro passo" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", {
|
||||||
|
name: "Também faço sites institucionais",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the systems proof with the Apolo Energia block, as the résumé says it, and a subtitle that starts from the pain", async () => {
|
||||||
|
const { container } = await renderService("engenharia-de-software");
|
||||||
|
|
||||||
|
const proof = container.querySelector<HTMLElement>("#casos-relacionados");
|
||||||
|
if (!proof) throw new Error("Missing proof block");
|
||||||
|
// o bloco da Apolo vem no topo da prova, antes dos cartões de case
|
||||||
|
const note = within(proof).getByText(/Apolo Energia/);
|
||||||
|
expect(note).toHaveTextContent(
|
||||||
|
"aplicações web e mobile para rotinas internas, APIs e painéis de operação",
|
||||||
|
);
|
||||||
|
expect(note.textContent).toMatch(/uso interno/);
|
||||||
|
expect(isBefore(note, caseCardLinks(container)[0])).toBe(true);
|
||||||
|
// o subtítulo parte da dor: planilha e retrabalho
|
||||||
|
const subtitle = screen.getByText(/planilhas, mensagens e retrabalho/);
|
||||||
|
expect(isBefore(screen.getByRole("heading", { level: 1 }), subtitle)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the offer name, not the old label, in the breadcrumb, the title and the WhatsApp message of every service", async () => {
|
||||||
|
for (const { slug, navLabel, whatsappSubject } of servicePages) {
|
||||||
|
const { unmount } = await renderService(slug);
|
||||||
|
|
||||||
|
const trail = within(
|
||||||
|
screen.getByRole("navigation", { name: "Trilha de navegação" }),
|
||||||
|
).getAllByRole("link");
|
||||||
|
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
|
||||||
|
for (const cta of screen.getAllByRole("link", {
|
||||||
|
name: buttonLabels.contact,
|
||||||
|
})) {
|
||||||
|
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
expect(text).toContain(`Quero conversar sobre ${whatsappSubject}.`);
|
||||||
|
expect(text).not.toMatch(
|
||||||
|
/Sites profissionais|Sites, sistemas e aplicativos/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => {
|
it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => {
|
||||||
@@ -674,7 +844,8 @@ it("says in one sentence, per case, why it matters to the service", async () =>
|
|||||||
container.querySelectorAll(`article a[href="/cases/${id}"]`),
|
container.querySelectorAll(`article a[href="/cases/${id}"]`),
|
||||||
).toHaveLength(0);
|
).toHaveLength(0);
|
||||||
} else {
|
} else {
|
||||||
const card = screen.getByText(sentence).closest("article");
|
// o cartão de "Onde já apliquei isso" ou a linha do bloco menor "Também faço sites institucionais"
|
||||||
|
const card = screen.getByText(sentence).closest("article, li");
|
||||||
expect(card).toContainElement(link);
|
expect(card).toContainElement(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,13 +886,8 @@ it("cites the Apolo Energia experience in text on the IoT and architecture pages
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mention Apolo Energia on the other service pages", async () => {
|
it("does not mention Apolo Energia on the other service pages (the systems page cites it, tested above)", async () => {
|
||||||
for (const slug of [
|
for (const slug of ["criacao-de-sites", "consultoria-nextjs", "tauri-rust"]) {
|
||||||
"criacao-de-sites",
|
|
||||||
"engenharia-de-software",
|
|
||||||
"consultoria-nextjs",
|
|
||||||
"tauri-rust",
|
|
||||||
]) {
|
|
||||||
const { container, unmount } = await renderService(slug);
|
const { container, unmount } = await renderService(slug);
|
||||||
expect(container.textContent).not.toMatch(/Apolo/);
|
expect(container.textContent).not.toMatch(/Apolo/);
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
|
||||||
import {
|
import {
|
||||||
compactHero,
|
|
||||||
heroWithMediaGrid,
|
|
||||||
} from "@/app/cases/_components/hero-styles";
|
|
||||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
|
||||||
import {
|
|
||||||
pageTop,
|
|
||||||
primaryButton,
|
primaryButton,
|
||||||
secondaryButtonDark,
|
secondaryButtonDark,
|
||||||
} from "@/app/components/button-styles";
|
} from "@/app/components/button-styles";
|
||||||
@@ -22,16 +15,20 @@ import {
|
|||||||
} from "@/lib/seo";
|
} from "@/lib/seo";
|
||||||
import {
|
import {
|
||||||
buttonLabels,
|
buttonLabels,
|
||||||
caseStudies,
|
getServiceOffer,
|
||||||
getRelatedCases,
|
|
||||||
getServicePage,
|
getServicePage,
|
||||||
servicePages,
|
servicePages,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
} from "@/lib/site-content";
|
} from "@/lib/site-content";
|
||||||
import { ServiceDiagram } from "../_components/ServiceDiagram";
|
import { ServiceAlsoCases } from "../_components/ServiceAlsoCases";
|
||||||
import { ServiceHeroCover } from "../_components/ServiceHeroCover";
|
import { ServiceDeliverables } from "../_components/ServiceDeliverables";
|
||||||
|
import { ServiceHero } from "../_components/ServiceHero";
|
||||||
import { ServiceOffer } from "../_components/ServiceOffer";
|
import { ServiceOffer } from "../_components/ServiceOffer";
|
||||||
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
|
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
|
||||||
|
import {
|
||||||
|
resolveServiceHero,
|
||||||
|
splitServiceCases,
|
||||||
|
} from "../_components/service-page";
|
||||||
|
|
||||||
interface ServicePageProps {
|
interface ServicePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -77,18 +74,21 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const relatedCases = getRelatedCases(service.relatedCaseIds);
|
const hero = resolveServiceHero(service);
|
||||||
// capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo
|
const { cards, also } = splitServiceCases(service);
|
||||||
const heroDevice = service.heroCase?.device ?? "desktop";
|
// as duas primeiras ofertas têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home
|
||||||
const heroCase = caseStudies.find(
|
const offer = getServiceOffer(service.slug);
|
||||||
({ id }) =>
|
|
||||||
id === service.heroCase?.id && Boolean(getCaseMedia(id, heroDevice)),
|
|
||||||
);
|
|
||||||
const diagram = heroCase ? undefined : service.diagram;
|
|
||||||
const hasHeroSide = Boolean(heroCase || diagram);
|
|
||||||
// o case da capa é citado pela legenda dela, e não ganha cartão em "Onde já apliquei isso"
|
|
||||||
const hasCaseCards = relatedCases.some(({ id }) => id !== heroCase?.id);
|
|
||||||
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
||||||
|
const audienceCard = (
|
||||||
|
<article className="ds-card">
|
||||||
|
<h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
|
||||||
|
<ul className="ds-list space-y-sp-2">
|
||||||
|
{service.audience.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -106,90 +106,44 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
<main>
|
<main>
|
||||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
|
|
||||||
<section
|
<ServiceHero service={service} hero={hero} />
|
||||||
className={`ds-section ds-section-dark ${pageTop} ${
|
|
||||||
hasHeroSide ? "" : compactHero
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
|
|
||||||
>
|
|
||||||
<div className="max-w-[860px]">
|
|
||||||
<Breadcrumb
|
|
||||||
items={[
|
|
||||||
{ label: "Início", href: "/" },
|
|
||||||
{ label: "Serviços", href: "/servicos" },
|
|
||||||
{ label: service.navLabel, href: `/servicos/${service.slug}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<p className="ds-eyebrow">{service.eyebrow}</p>
|
|
||||||
<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)]">
|
|
||||||
{service.answer}
|
|
||||||
</p>
|
|
||||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
|
||||||
<WhatsAppLink
|
|
||||||
service={service.navLabel}
|
|
||||||
placement="service_detail_hero"
|
|
||||||
className={primaryButton}
|
|
||||||
>
|
|
||||||
{buttonLabels.contact}
|
|
||||||
</WhatsAppLink>
|
|
||||||
{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">
|
|
||||||
O botão abre uma conversa no WhatsApp, sem formulário. Garuva e
|
|
||||||
Joinville (SC) · atendimento remoto em todo o Brasil.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{diagram ? (
|
|
||||||
<ServiceDiagram kind={diagram} />
|
|
||||||
) : (
|
|
||||||
<ServiceHeroCover
|
|
||||||
caseStudy={heroCase}
|
|
||||||
device={heroDevice}
|
|
||||||
relevance={
|
|
||||||
heroCase ? service.caseRelevance[heroCase.id] : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ServiceRelatedCases
|
<ServiceRelatedCases
|
||||||
cases={relatedCases}
|
cases={cards}
|
||||||
relevance={service.caseRelevance}
|
relevance={service.caseRelevance}
|
||||||
experienceNote={service.experienceNote}
|
experienceNote={service.experienceNote}
|
||||||
heroCaseId={heroCase?.id}
|
heroCaseId={hero.heroCase?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{also ? (
|
||||||
|
<ServiceAlsoCases
|
||||||
|
title={also.title}
|
||||||
|
cases={also.cases}
|
||||||
|
relevance={service.caseRelevance}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{offer ? <ServiceDeliverables offer={offer} /> : null}
|
||||||
|
|
||||||
<section className="ds-section">
|
<section className="ds-section">
|
||||||
<div className="ds-section-shell">
|
<div className="ds-section-shell">
|
||||||
<div className="ds-grid-2">
|
{offer ? (
|
||||||
<article className="ds-card">
|
// a página da oferta já tem "O que você recebe": uma lista de entregáveis só, sem "O que podemos entregar"
|
||||||
<h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
|
audienceCard
|
||||||
<ul className="ds-list space-y-sp-2">
|
) : (
|
||||||
{service.audience.map((item) => (
|
<div className="ds-grid-2">
|
||||||
<li key={item}>{item}</li>
|
{audienceCard}
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="ds-card">
|
<article className="ds-card">
|
||||||
<h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
|
<h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
|
||||||
<ul className="ds-list space-y-sp-2">
|
<ul className="ds-list space-y-sp-2">
|
||||||
{service.outcomes.map((item) => (
|
{service.outcomes.map((item) => (
|
||||||
<li key={item}>{item}</li>
|
<li key={item}>{item}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -223,7 +177,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-section-shell mt-sp-8">
|
<div className="ds-section-shell !mt-sp-8">
|
||||||
<details className="border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
|
<details className="border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
|
||||||
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
|
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
|
||||||
Detalhes técnicos e tecnologias
|
Detalhes técnicos e tecnologias
|
||||||
@@ -268,7 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||||
<WhatsAppLink
|
<WhatsAppLink
|
||||||
service={service.navLabel}
|
service={service.whatsappSubject}
|
||||||
placement="service_detail_footer"
|
placement="service_detail_footer"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
|
import { type CaseStudyContent, caseStudies } from "@/lib/site-content";
|
||||||
|
import { ServiceAlsoCases } from "./ServiceAlsoCases";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceAlsoCases (CC 3): sem case (nada) | com case; frase de relação própria | só o resumo do case.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/servicos" });
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCase(id: string): CaseStudyContent {
|
||||||
|
const found = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!found) throw new Error(`Missing case ${id}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ServiceAlsoCases", () => {
|
||||||
|
it("path: cases with a relation sentence -> one line per case, with the title, the sentence and a 'Ver case' link, and no cover", () => {
|
||||||
|
render(
|
||||||
|
<ServiceAlsoCases
|
||||||
|
title="Também faço sites institucionais"
|
||||||
|
cases={[getCase("sc-plus"), getCase("as-servicos")]}
|
||||||
|
relevance={{
|
||||||
|
"sc-plus": "Frase do SC Plus.",
|
||||||
|
"as-servicos": "Frase da AS.",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Também faço sites institucionais",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
const rows = screen.getAllByRole("listitem");
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
within(rows[0]).getByRole("heading", {
|
||||||
|
level: 3,
|
||||||
|
name: "SC Plus Aquicultura",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(within(rows[0]).getByText("Frase do SC Plus.")).toBeVisible();
|
||||||
|
expect(
|
||||||
|
within(rows[0]).getByRole("link", {
|
||||||
|
name: "Ver case SC Plus Aquicultura",
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", "/cases/sc-plus");
|
||||||
|
expect(
|
||||||
|
within(rows[1]).getByRole("link", { name: "Ver case AS Serviços" }),
|
||||||
|
).toHaveAttribute("href", "/cases/as-servicos");
|
||||||
|
expect(screen.queryByRole("img")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no relation sentence for a case -> the summary of the case stands in", () => {
|
||||||
|
const caseStudy = getCase("sc-plus");
|
||||||
|
render(
|
||||||
|
<ServiceAlsoCases title="Também" cases={[caseStudy]} relevance={{}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no case -> renders nothing", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ServiceAlsoCases title="Também" cases={[]} relevance={{}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { textLink } from "@/app/components/button-styles";
|
||||||
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloco menor depois de "Onde já apliquei isso": trabalhos parecidos que não são a prova principal do serviço, em uma linha
|
||||||
|
* cada (nome, por que interessam e o link do case), sem capa. Sem case, não mostra nada.
|
||||||
|
*/
|
||||||
|
export function ServiceAlsoCases({
|
||||||
|
title,
|
||||||
|
cases,
|
||||||
|
relevance,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
cases: readonly CaseStudyContent[];
|
||||||
|
/** Por que cada case interessa ao serviço (chave: id do case); sem frase própria, vale o resumo do case. */
|
||||||
|
relevance: Readonly<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
if (cases.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ds-section" aria-labelledby="tambem-faco-titulo">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<h2 id="tambem-faco-titulo" className="ds-card-title !mt-0">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-sp-6 divide-y divide-[var(--border)] border-y border-[var(--border)]">
|
||||||
|
{cases.map((caseStudy) => (
|
||||||
|
<li
|
||||||
|
key={caseStudy.id}
|
||||||
|
className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] md:gap-sp-8"
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold leading-6">
|
||||||
|
{caseStudy.title}
|
||||||
|
</h3>
|
||||||
|
<p className="ds-copy !mt-0">
|
||||||
|
{relevance[caseStudy.id] ?? caseStudy.summary}
|
||||||
|
</p>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={`/cases/${caseStudy.id}`}
|
||||||
|
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
|
||||||
|
>
|
||||||
|
{buttonLabels.case}
|
||||||
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { serviceOffers } from "@/lib/site-content";
|
||||||
|
import { ServiceDeliverables } from "./ServiceDeliverables";
|
||||||
|
|
||||||
|
describe("ServiceDeliverables", () => {
|
||||||
|
it("shows 'O que você recebe' with the deliverables of the offer, then 'Primeiro passo' with its first step", () => {
|
||||||
|
const [offer] = serviceOffers;
|
||||||
|
render(<ServiceDeliverables offer={offer} />);
|
||||||
|
|
||||||
|
const receives = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "O que você recebe",
|
||||||
|
});
|
||||||
|
const list = within(receives.closest("article") as HTMLElement).getByRole(
|
||||||
|
"list",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(list)
|
||||||
|
.getAllByRole("listitem")
|
||||||
|
.map((item) => item.textContent),
|
||||||
|
).toEqual(offer.deliverables);
|
||||||
|
|
||||||
|
const firstStep = screen.getByRole("heading", {
|
||||||
|
level: 2,
|
||||||
|
name: "Primeiro passo",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
receives.compareDocumentPosition(firstStep) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByText(offer.firstStep)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the section by its heading and has no contact button or link of its own", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ServiceDeliverables offer={serviceOffers[1]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: "O que você recebe" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(container.querySelectorAll("a")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ServiceOffer } from "@/lib/site-content";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "O que você recebe" e "Primeiro passo" da oferta. A lista é a mesma do cartão da home (`serviceOffers` em
|
||||||
|
* lib/site-content.ts): a página repete o que o cartão prometeu. O primeiro passo é o da oferta, que termina como a frase
|
||||||
|
* geral da home (`generalFirstStep`): uma conversa e, depois dela, escopo e proposta por escrito. Só as páginas que
|
||||||
|
* explicam uma oferta mostram este bloco.
|
||||||
|
*/
|
||||||
|
export function ServiceDeliverables({
|
||||||
|
offer,
|
||||||
|
}: {
|
||||||
|
offer: Pick<ServiceOffer, "deliverables" | "firstStep">;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="ds-section" aria-labelledby="o-que-voce-recebe">
|
||||||
|
<div className="ds-section-shell">
|
||||||
|
<div className="ds-grid-2">
|
||||||
|
<article className="ds-card">
|
||||||
|
<h2 id="o-que-voce-recebe" className="ds-card-title !mt-0">
|
||||||
|
O que você recebe
|
||||||
|
</h2>
|
||||||
|
<ul className="ds-list space-y-sp-2">
|
||||||
|
{offer.deliverables.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="ds-card">
|
||||||
|
<h2 className="ds-card-title !mt-0">Primeiro passo</h2>
|
||||||
|
<p className="ds-copy">{offer.firstStep}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
compactHero,
|
||||||
|
heroWithMediaGrid,
|
||||||
|
} from "@/app/cases/_components/hero-styles";
|
||||||
|
import {
|
||||||
|
caseStudies,
|
||||||
|
getServicePage,
|
||||||
|
type ServicePageContent,
|
||||||
|
} from "@/lib/site-content";
|
||||||
|
import { ServiceHero } from "./ServiceHero";
|
||||||
|
import type { ServiceHeroData } from "./service-page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testes de caminhos-base de ServiceHero (CC 6, contado à mão: as cinco escolhas do JSX):
|
||||||
|
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide (grade de duas colunas), hasCaseCards (botão "Ver projetos
|
||||||
|
* parecidos"), diagram (esquema ou capa) e heroCase (a frase da capa).
|
||||||
|
* Os dois filhos que desenham a coluna ao lado (esquema e capa) são trocados por dublês que mostram o que receberam.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("./ServiceDiagram", () => ({
|
||||||
|
ServiceDiagram: ({ kind }: { kind: string }) => (
|
||||||
|
<div data-testid="diagram">{kind}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
jest.mock("./ServiceHeroCover", () => ({
|
||||||
|
ServiceHeroCover: (props: {
|
||||||
|
caseStudy?: { id: string };
|
||||||
|
device?: string;
|
||||||
|
relevance?: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="cover"
|
||||||
|
data-case={props.caseStudy?.id ?? ""}
|
||||||
|
data-device={props.device ?? ""}
|
||||||
|
data-relevance={props.relevance ?? ""}
|
||||||
|
data-has-relevance={String(props.relevance !== undefined)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function service(slug = "criacao-de-sites"): ServicePageContent {
|
||||||
|
const found = getServicePage(slug);
|
||||||
|
if (!found) throw new Error(`Missing service ${slug}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseStudy(id: string) {
|
||||||
|
const found = caseStudies.find((item) => item.id === id);
|
||||||
|
if (!found) throw new Error(`Missing case ${id}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noSide: ServiceHeroData = {
|
||||||
|
heroCase: undefined,
|
||||||
|
heroDevice: "desktop",
|
||||||
|
diagram: undefined,
|
||||||
|
hasHeroSide: false,
|
||||||
|
hasCaseCards: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const shell = (container: HTMLElement) =>
|
||||||
|
container.querySelector(".ds-section-shell") as HTMLElement;
|
||||||
|
const section = (container: HTMLElement) =>
|
||||||
|
container.querySelector("section") as HTMLElement;
|
||||||
|
const projectsLink = () =>
|
||||||
|
screen.queryByRole("link", { name: "Ver projetos parecidos" });
|
||||||
|
|
||||||
|
describe("ServiceHero", () => {
|
||||||
|
it("path: no cover, no diagram and no card -> one low column, no 'Ver projetos parecidos', and an empty slot where the cover would be", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ServiceHero service={service()} hero={noSide} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const token of compactHero.split(/\s+/).filter(Boolean))
|
||||||
|
expect(section(container)).toHaveClass(token);
|
||||||
|
for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean))
|
||||||
|
expect(shell(container)).not.toHaveClass(token);
|
||||||
|
expect(projectsLink()).toBeNull();
|
||||||
|
expect(screen.queryByTestId("diagram")).toBeNull();
|
||||||
|
// sem esquema, quem ocupa a coluna é a capa, que por conta própria não desenha nada sem case
|
||||||
|
expect(screen.getByTestId("cover")).toHaveAttribute("data-case", "");
|
||||||
|
expect(screen.getByTestId("cover")).toHaveAttribute(
|
||||||
|
"data-has-relevance",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a side column -> the compact class goes away and the two-column grid comes in", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ServiceHero
|
||||||
|
service={service("tauri-rust")}
|
||||||
|
hero={{ ...noSide, diagram: "desktop", hasHeroSide: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const token of compactHero.split(/\s+/).filter(Boolean))
|
||||||
|
expect(section(container)).not.toHaveClass(token);
|
||||||
|
for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean))
|
||||||
|
expect(shell(container)).toHaveClass(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: cards below -> 'Ver projetos parecidos' points at the related cases", () => {
|
||||||
|
render(
|
||||||
|
<ServiceHero
|
||||||
|
service={service()}
|
||||||
|
hero={{ ...noSide, hasCaseCards: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(projectsLink()).toHaveAttribute("href", "#casos-relacionados");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a diagram -> the diagram fills the side column and the cover is not mounted", () => {
|
||||||
|
render(
|
||||||
|
<ServiceHero
|
||||||
|
service={service("integracao-hardware-software-iot")}
|
||||||
|
hero={{ ...noSide, diagram: "iot", hasHeroSide: true }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("diagram")).toHaveTextContent("iot");
|
||||||
|
expect(screen.queryByTestId("cover")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a cover case -> the cover gets the case, the device and the service's sentence about that case", () => {
|
||||||
|
const lavacao = service();
|
||||||
|
render(
|
||||||
|
<ServiceHero
|
||||||
|
service={lavacao}
|
||||||
|
hero={{
|
||||||
|
...noSide,
|
||||||
|
heroCase: caseStudy("lavacao-047"),
|
||||||
|
hasHeroSide: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cover = screen.getByTestId("cover");
|
||||||
|
expect(cover).toHaveAttribute("data-case", "lavacao-047");
|
||||||
|
expect(cover).toHaveAttribute("data-device", "desktop");
|
||||||
|
expect(cover).toHaveAttribute(
|
||||||
|
"data-relevance",
|
||||||
|
lavacao.caseRelevance["lavacao-047"],
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("diagram")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a cover case the service has no sentence for -> the cover gets no sentence (it falls back on its own)", () => {
|
||||||
|
// o serviço de esquema não tem frase para a SEV
|
||||||
|
const withoutSentence = service("tauri-rust");
|
||||||
|
expect(withoutSentence.caseRelevance["sev-modaintima"]).toBeUndefined();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ServiceHero
|
||||||
|
service={withoutSentence}
|
||||||
|
hero={{
|
||||||
|
...noSide,
|
||||||
|
heroCase: caseStudy("sev-modaintima"),
|
||||||
|
heroDevice: "mobile",
|
||||||
|
hasHeroSide: true,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cover = screen.getByTestId("cover");
|
||||||
|
expect(cover).toHaveAttribute("data-case", "sev-modaintima");
|
||||||
|
expect(cover).toHaveAttribute("data-device", "mobile");
|
||||||
|
expect(cover).toHaveAttribute("data-has-relevance", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the contact button speaks of the service by its whatsappSubject, in lower case, with the hero placement", () => {
|
||||||
|
const sistemas = service("engenharia-de-software");
|
||||||
|
render(<ServiceHero service={sistemas} hero={noSide} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("link", {
|
||||||
|
name: /falar sobre meu projeto/i,
|
||||||
|
});
|
||||||
|
const message = new URL(button.getAttribute("href") ?? "").searchParams.get(
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
expect(message).toContain(
|
||||||
|
`Quero conversar sobre ${sistemas.whatsappSubject}.`,
|
||||||
|
);
|
||||||
|
expect(sistemas.whatsappSubject).toMatch(/^[a-zà-ú]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
compactHero,
|
||||||
|
heroWithMediaGrid,
|
||||||
|
} from "@/app/cases/_components/hero-styles";
|
||||||
|
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||||
|
import {
|
||||||
|
pageTop,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButtonDark,
|
||||||
|
} from "@/app/components/button-styles";
|
||||||
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
|
import { buttonLabels, type ServicePageContent } from "@/lib/site-content";
|
||||||
|
import { ServiceDiagram } from "./ServiceDiagram";
|
||||||
|
import { ServiceHeroCover } from "./ServiceHeroCover";
|
||||||
|
import type { ServiceHeroData } from "./service-page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topo da página do serviço: trilha, título, resposta, botão de contato e, na coluna ao lado, a capa do case escolhido
|
||||||
|
* ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo.
|
||||||
|
*/
|
||||||
|
export function ServiceHero({
|
||||||
|
service,
|
||||||
|
hero,
|
||||||
|
}: {
|
||||||
|
service: ServicePageContent;
|
||||||
|
hero: ServiceHeroData;
|
||||||
|
}) {
|
||||||
|
const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`ds-section ds-section-dark ${pageTop} ${
|
||||||
|
hasHeroSide ? "" : compactHero
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-[860px]">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Início", href: "/" },
|
||||||
|
{ label: "Serviços", href: "/servicos" },
|
||||||
|
{ label: service.navLabel, href: `/servicos/${service.slug}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="ds-eyebrow">{service.eyebrow}</p>
|
||||||
|
<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)]">
|
||||||
|
{service.answer}
|
||||||
|
</p>
|
||||||
|
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||||
|
<WhatsAppLink
|
||||||
|
service={service.whatsappSubject}
|
||||||
|
placement="service_detail_hero"
|
||||||
|
className={primaryButton}
|
||||||
|
>
|
||||||
|
{buttonLabels.contact}
|
||||||
|
</WhatsAppLink>
|
||||||
|
{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">
|
||||||
|
O botão abre uma conversa no WhatsApp, sem formulário. Garuva e
|
||||||
|
Joinville (SC) · atendimento remoto em todo o Brasil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{diagram ? (
|
||||||
|
<ServiceDiagram kind={diagram} />
|
||||||
|
) : (
|
||||||
|
<ServiceHeroCover
|
||||||
|
caseStudy={heroCase}
|
||||||
|
device={heroDevice}
|
||||||
|
relevance={
|
||||||
|
heroCase ? service.caseRelevance[heroCase.id] : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||||
import { caseStudies, type ServiceOffer } from "@/lib/site-content";
|
import { caseStudies, type ServiceOffer } from "@/lib/site-content";
|
||||||
import { ServiceOfferCards } from "./ServiceOfferCards";
|
import { ServiceOfferCards } from "./ServiceOfferCards";
|
||||||
|
|
||||||
@@ -7,15 +8,19 @@ import { ServiceOfferCards } from "./ServiceOfferCards";
|
|||||||
* com cases | sem cases (a lista de links some); com observação | sem observação.
|
* com cases | sem cases (a lista de links some); com observação | sem observação.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
jest.mock("next/navigation", () => ({ usePathname: () => "/servicos" }));
|
beforeEach(() => {
|
||||||
|
setNavigation({ pathname: "/servicos" });
|
||||||
|
});
|
||||||
|
|
||||||
function offer(overrides: Partial<ServiceOffer["proof"]>): ServiceOffer {
|
function offer(overrides: Partial<ServiceOffer["proof"]>): ServiceOffer {
|
||||||
return {
|
return {
|
||||||
id: "site",
|
id: "site",
|
||||||
name: "Oferta de teste",
|
name: "Oferta de teste",
|
||||||
|
whatsappSubject: "uma oferta de teste",
|
||||||
summary: "Resumo.",
|
summary: "Resumo.",
|
||||||
href: "/servicos/criacao-de-sites",
|
href: "/servicos/criacao-de-sites",
|
||||||
deliverables: ["Um", "Dois", "Três"],
|
deliverables: ["Um", "Dois", "Três"],
|
||||||
|
firstStep: "Primeira conversa. Depois, escopo e proposta por escrito.",
|
||||||
proof: { label: "Veja funcionando", caseIds: [], ...overrides },
|
proof: { label: "Veja funcionando", caseIds: [], ...overrides },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { getServicePage, type ServicePageContent } from "@/lib/site-content";
|
||||||
|
import { resolveServiceHero, splitServiceCases } from "./service-page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caminhos-base de resolveServiceHero (CC 6) e splitServiceCases (CC 3) que service-page.test.ts não separa, todos com um
|
||||||
|
* serviço montado à mão, porque nenhum serviço real reúne essas combinações:
|
||||||
|
* - capa e esquema ao mesmo tempo: a capa vale e o esquema some; sem captura da capa, o esquema entra no lugar;
|
||||||
|
* - os cases do bloco menor não contam para o botão "Ver projetos parecidos" (ele leva a "Onde já apliquei isso");
|
||||||
|
* - todos os cases no bloco menor: nenhum cartão, e o bloco leva todos, na ordem de `relatedCaseIds`.
|
||||||
|
* A existência das capturas em disco é simulada em getCaseMedia.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||||
|
getCaseMedia: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
function service(overrides: Partial<ServicePageContent>): ServicePageContent {
|
||||||
|
const base = getServicePage("consultoria-nextjs");
|
||||||
|
if (!base) throw new Error("Missing service consultoria-nextjs");
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
heroCase: undefined,
|
||||||
|
diagram: undefined,
|
||||||
|
alsoCases: undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Só as capturas listadas existem no disco. */
|
||||||
|
function capturesOnDisk(captures: readonly (readonly [string, string])[]) {
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
captures.some(
|
||||||
|
([knownId, knownDevice]) => knownId === id && knownDevice === device,
|
||||||
|
)
|
||||||
|
? getShowcaseAssets(id, device)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveServiceHero: combinações que os serviços reais não têm", () => {
|
||||||
|
it("path: a cover with its capture AND a diagram -> the cover wins and the diagram is dropped", () => {
|
||||||
|
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(
|
||||||
|
service({
|
||||||
|
relatedCaseIds: ["lavacao-047"],
|
||||||
|
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||||
|
diagram: "iot",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||||
|
expect(hero.diagram).toBeUndefined();
|
||||||
|
expect(hero.hasHeroSide).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a cover WITHOUT its capture and a diagram -> the diagram takes the side column", () => {
|
||||||
|
capturesOnDisk([]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(
|
||||||
|
service({
|
||||||
|
relatedCaseIds: ["lavacao-047"],
|
||||||
|
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||||
|
diagram: "iot",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hero.heroCase).toBeUndefined();
|
||||||
|
expect(hero.diagram).toBe("iot");
|
||||||
|
expect(hero.hasHeroSide).toBe(true);
|
||||||
|
// a capa não valeu, então o case não está citado em legenda nenhuma: ele ganha o cartão
|
||||||
|
expect(hero.hasCaseCards).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the only card left after the smaller block is the cover -> no 'Ver projetos parecidos' target, even with related cases in the block", () => {
|
||||||
|
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(
|
||||||
|
service({
|
||||||
|
relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"],
|
||||||
|
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||||
|
alsoCases: { title: "Também", caseIds: ["sc-plus", "as-servicos"] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||||
|
expect(hero.hasCaseCards).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a cover in the smaller block as well -> it is not a card, and the cover stays", () => {
|
||||||
|
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(
|
||||||
|
service({
|
||||||
|
relatedCaseIds: ["lavacao-047"],
|
||||||
|
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||||
|
alsoCases: { title: "Também", caseIds: ["lavacao-047"] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||||
|
expect(hero.hasCaseCards).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitServiceCases: o bloco leva todos os cases", () => {
|
||||||
|
it("path: every related case in the smaller block -> no card at all, and the block keeps the order of relatedCaseIds", () => {
|
||||||
|
const { cards, also } = splitServiceCases(
|
||||||
|
service({
|
||||||
|
relatedCaseIds: ["sc-plus", "as-servicos"],
|
||||||
|
// fora de ordem de propósito: quem manda é a ordem de `relatedCaseIds`
|
||||||
|
alsoCases: { title: "Também", caseIds: ["as-servicos", "sc-plus"] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cards).toEqual([]);
|
||||||
|
expect(also?.title).toBe("Também");
|
||||||
|
expect(also?.cases.map(({ id }) => id)).toEqual(["sc-plus", "as-servicos"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a smaller block with an empty list of ids -> null, and every related case stays a card", () => {
|
||||||
|
const { cards, also } = splitServiceCases(
|
||||||
|
service({
|
||||||
|
relatedCaseIds: ["sc-plus", "lavacao-047"],
|
||||||
|
alsoCases: { title: "Também", caseIds: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(also).toBeNull();
|
||||||
|
expect(cards.map(({ id }) => id)).toEqual(["sc-plus", "lavacao-047"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||||
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { getServicePage, type ServicePageContent } from "@/lib/site-content";
|
||||||
|
import { resolveServiceHero, splitServiceCases } from "./service-page";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveServiceHero (CC 6): capa com captura | sem captura do aparelho pedido; esquema | sem esquema; o aparelho padrão
|
||||||
|
* (computador) | o do serviço (celular); algum case com cartão | só o da capa | nenhum.
|
||||||
|
* splitServiceCases (CC 3): serviço com bloco menor | sem ele; ids do bloco que o serviço relaciona | que não relaciona.
|
||||||
|
* A existência das capturas em disco é simulada em getCaseMedia.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||||
|
getCaseMedia: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
|
|
||||||
|
function service(slug: string): ServicePageContent {
|
||||||
|
const found = getServicePage(slug);
|
||||||
|
if (!found) throw new Error(`Missing service ${slug}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Só as capturas listadas existem no disco. */
|
||||||
|
function capturesOnDisk(captures: readonly (readonly [string, string])[]) {
|
||||||
|
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||||
|
captures.some(
|
||||||
|
([knownId, knownDevice]) => knownId === id && knownDevice === device,
|
||||||
|
)
|
||||||
|
? getShowcaseAssets(id, device)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedGetCaseMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveServiceHero", () => {
|
||||||
|
it("path: cover with its capture on disk -> the case is the cover, no diagram, two columns", () => {
|
||||||
|
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(service("criacao-de-sites"));
|
||||||
|
|
||||||
|
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||||
|
expect(hero.heroDevice).toBe("desktop");
|
||||||
|
expect(hero.diagram).toBeUndefined();
|
||||||
|
expect(hero.hasHeroSide).toBe(true);
|
||||||
|
// a SEV, que não é a capa, ganha cartão
|
||||||
|
expect(hero.hasCaseCards).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: a service that asks for the phone -> the capture is checked for the phone", () => {
|
||||||
|
capturesOnDisk([["sev-modaintima", "mobile"]]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(service("engenharia-de-software"));
|
||||||
|
|
||||||
|
expect(hero.heroDevice).toBe("mobile");
|
||||||
|
expect(hero.heroCase?.id).toBe("sev-modaintima");
|
||||||
|
expect(mockedGetCaseMedia).toHaveBeenCalledWith("sev-modaintima", "mobile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: cover without a capture and no diagram -> no side column, and the case that would be the cover gets its card back", () => {
|
||||||
|
capturesOnDisk([]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(service("criacao-de-sites"));
|
||||||
|
|
||||||
|
expect(hero.heroCase).toBeUndefined();
|
||||||
|
expect(hero.diagram).toBeUndefined();
|
||||||
|
expect(hero.hasHeroSide).toBe(false);
|
||||||
|
expect(hero.hasCaseCards).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: service with a diagram -> the diagram fills the side column, desktop by default", () => {
|
||||||
|
capturesOnDisk([]);
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(service("tauri-rust"));
|
||||||
|
|
||||||
|
expect(hero.heroCase).toBeUndefined();
|
||||||
|
expect(hero.heroDevice).toBe("desktop");
|
||||||
|
expect(hero.diagram).toBe("desktop");
|
||||||
|
expect(hero.hasHeroSide).toBe(true);
|
||||||
|
expect(hero.hasCaseCards).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the only related case is the cover -> no card, so no 'Ver projetos parecidos' target", () => {
|
||||||
|
capturesOnDisk([["sc-plus", "desktop"]]);
|
||||||
|
const onlyCover: ServicePageContent = {
|
||||||
|
...service("consultoria-nextjs"),
|
||||||
|
relatedCaseIds: ["sc-plus"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const hero = resolveServiceHero(onlyCover);
|
||||||
|
|
||||||
|
expect(hero.heroCase?.id).toBe("sc-plus");
|
||||||
|
expect(hero.hasCaseCards).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no related case at all -> no cards", () => {
|
||||||
|
capturesOnDisk([]);
|
||||||
|
|
||||||
|
expect(resolveServiceHero(service("arquitetura-de-software"))).toEqual({
|
||||||
|
heroCase: undefined,
|
||||||
|
heroDevice: "desktop",
|
||||||
|
diagram: "arquitetura",
|
||||||
|
hasHeroSide: true,
|
||||||
|
hasCaseCards: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitServiceCases", () => {
|
||||||
|
it("path: service with a smaller block -> the sites institucionais leave the cards and go to the block", () => {
|
||||||
|
const { cards, also } = splitServiceCases(service("criacao-de-sites"));
|
||||||
|
|
||||||
|
expect(cards.map(({ id }) => id)).toEqual([
|
||||||
|
"lavacao-047",
|
||||||
|
"sev-modaintima",
|
||||||
|
]);
|
||||||
|
expect(also?.title).toBe("Também faço sites institucionais");
|
||||||
|
expect(also?.cases.map(({ id }) => id)).toEqual(["sc-plus", "as-servicos"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: service without a smaller block -> every related case is a card and `also` is null", () => {
|
||||||
|
const { cards, also } = splitServiceCases(
|
||||||
|
service("engenharia-de-software"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cards.map(({ id }) => id)).toEqual([
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
]);
|
||||||
|
expect(also).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: block ids that the service does not relate -> ignored, and with none left the block is null", () => {
|
||||||
|
const stray: ServicePageContent = {
|
||||||
|
...service("consultoria-nextjs"),
|
||||||
|
alsoCases: { title: "Também", caseIds: ["as-servicos", "nao-existe"] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { cards, also } = splitServiceCases(stray);
|
||||||
|
|
||||||
|
expect(cards.map(({ id }) => id)).toEqual(["sc-plus", "lavacao-047"]);
|
||||||
|
expect(also).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||||
|
import {
|
||||||
|
type CaseStudyContent,
|
||||||
|
caseStudies,
|
||||||
|
getRelatedCases,
|
||||||
|
type HeroDevice,
|
||||||
|
type ServiceDiagramKind,
|
||||||
|
type ServicePageContent,
|
||||||
|
} from "@/lib/site-content";
|
||||||
|
|
||||||
|
/** O que ocupa a coluna ao lado do título do serviço, e se há "Onde já apliquei isso" para o botão do topo levar até lá. */
|
||||||
|
export interface ServiceHeroData {
|
||||||
|
/** Case da capa: o escolhido para o serviço, quando tem captura gravada do aparelho pedido. */
|
||||||
|
heroCase?: CaseStudyContent;
|
||||||
|
heroDevice: HeroDevice;
|
||||||
|
/** Esquema de fluxo no lugar da capa; só existe quando não há case com captura. */
|
||||||
|
diagram?: ServiceDiagramKind;
|
||||||
|
/** Há coluna ao lado do título (capa ou esquema): sem ela, o topo é uma coluna só e mais baixo. */
|
||||||
|
hasHeroSide: boolean;
|
||||||
|
/** Algum case ganha cartão em "Onde já apliquei isso" (o da capa não ganha: a legenda dela o cita). */
|
||||||
|
hasCaseCards: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Os cases do serviço, em dois grupos: os cartões de "Onde já apliquei isso" e os do bloco menor `alsoCases`
|
||||||
|
* (sem capa), que o serviço pode declarar. Sem `alsoCases`, `also` é nulo.
|
||||||
|
*/
|
||||||
|
export function splitServiceCases(service: ServicePageContent) {
|
||||||
|
const related = getRelatedCases(service.relatedCaseIds);
|
||||||
|
const alsoIds = new Set(service.alsoCases?.caseIds);
|
||||||
|
const alsoCases = related.filter(({ id }) => alsoIds.has(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: related.filter(({ id }) => !alsoIds.has(id)),
|
||||||
|
also:
|
||||||
|
service.alsoCases && alsoCases.length > 0
|
||||||
|
? { title: service.alsoCases.title, cases: alsoCases }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo.
|
||||||
|
* O case da capa é citado pela legenda dela e não ganha cartão em "Onde já apliquei isso".
|
||||||
|
*/
|
||||||
|
export function resolveServiceHero(
|
||||||
|
service: ServicePageContent,
|
||||||
|
): ServiceHeroData {
|
||||||
|
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 { cards } = splitServiceCases(service);
|
||||||
|
|
||||||
|
return {
|
||||||
|
heroCase,
|
||||||
|
heroDevice,
|
||||||
|
diagram,
|
||||||
|
hasHeroSide: Boolean(heroCase || diagram),
|
||||||
|
hasCaseCards: cards.some(({ id }) => id !== heroCase?.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ const pageTitle = "Sites, sistemas e aplicativos sob medida";
|
|||||||
const pageDescription =
|
const pageDescription =
|
||||||
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Garuva e Joinville (SC), remoto em todo o Brasil.";
|
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Garuva e Joinville (SC), remoto em todo o Brasil.";
|
||||||
|
|
||||||
|
/** Cliente que não entra no mosaico do topo: a lista de /cases abre com a SEV (ordem da vitrine), mas o hub mostra os outros três. */
|
||||||
|
const MOSAIC_EXCLUDED_ID = "sev-modaintima";
|
||||||
|
|
||||||
/** Páginas que as três ofertas já abrem; os demais serviços ficam na lista "Outros serviços". */
|
/** 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 offerHrefs = new Set(serviceOffers.map(({ href }) => href));
|
||||||
const otherServices = serviceHubLinks.filter(
|
const otherServices = serviceHubLinks.filter(
|
||||||
@@ -69,9 +72,13 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ServicesIndexPage() {
|
export default function ServicesIndexPage() {
|
||||||
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (ela vai por último)
|
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (o hub não abre com ela)
|
||||||
// e nenhum cliente se repete mais abaixo, onde as ofertas citam os cases só por link
|
// e nenhum cliente se repete mais abaixo, onde as ofertas citam os cases só por link
|
||||||
const posters = getMosaicPosters(clientCasesForListing.map(({ id }) => id));
|
const posters = getMosaicPosters(
|
||||||
|
clientCasesForListing
|
||||||
|
.map(({ id }) => id)
|
||||||
|
.filter((id) => id !== MOSAIC_EXCLUDED_ID),
|
||||||
|
);
|
||||||
const hasMosaic = posters.length > 0;
|
const hasMosaic = posters.length > 0;
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ A matriz do design fecha a cobertura em seis grupos. Os números abaixo são rec
|
|||||||
| --- | --- | ---: | --- |
|
| --- | --- | ---: | --- |
|
||||||
| Google | G1–G13 | 13 | Conteúdo útil e original, uma canônica por intenção, schema visível, frescor sem datas fabricadas e medição no Search Console. |
|
| Google | G1–G13 | 13 | Conteúdo útil e original, uma canônica por intenção, schema visível, frescor sem datas fabricadas e medição no Search Console. |
|
||||||
| Bing + Web Vitals | B1–B3, W1 | 4 | Resposta factual cedo, páginas focadas, evidência e AI Performance; sem IndexNow prematuro e sem score alegado. |
|
| Bing + Web Vitals | B1–B3, W1 | 4 | Resposta factual cedo, páginas focadas, evidência e AI Performance; sem IndexNow prematuro e sem score alegado. |
|
||||||
| Schema.org | S1–S6 | 6 | `Person`, `Service`, `ProfilePage` e `BreadcrumbList` mínimos, conectados e coerentes com o conteúdo visível. |
|
| Schema.org | S1–S6 | 6 | `Person`, `Service`, `ProfilePage` e `BreadcrumbList` mínimos, conectados e coerentes com o conteúdo visível. A home descreve as três ofertas como nós `Service` (com `provider` ligado à `Person` e `areaServed`); a revisão aponta para `/ia`. O `areaServed` é um valor só em todo nó `Service`, home e páginas de serviço: Garuva, Joinville, Santa Catarina e Brasil, como no texto visível. Sem `ProfessionalService`, `LocalBusiness` nem catálogo de ofertas. |
|
||||||
| Next.js | N1–N7 | 7 | Metadata por rota, `generateMetadata`, imagens por segmento e build estático, sem pacote novo. |
|
| Next.js | N1–N7 | 7 | Metadata por rota, `generateMetadata`, imagens por segmento e build estático, sem pacote novo. |
|
||||||
| GEO/crawlers | A1–A2, C1–C4, L1 | 7 | Fontes visíveis, medição como experimento e política de bots apenas documentada; sem causalidade de citação. |
|
| GEO/crawlers | A1–A2, C1–C4, L1 | 7 | Fontes visíveis, medição como experimento e política de bots apenas documentada; sem causalidade de citação. |
|
||||||
| Mercado observado | — | 20 | Clareza de intenção, links, projetos e prova de primeira mão; sem stuffing, testemunho, métrica ou autoridade inferida. |
|
| Mercado observado | — | 20 | Clareza de intenção, links, projetos e prova de primeira mão; sem stuffing, testemunho, métrica ou autoridade inferida. |
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Nome de cada uma das três ofertas e a frase que a mensagem de WhatsApp usa como assunto ("Quero conversar sobre {assunto}.").
|
||||||
|
* É a única fonte desses textos: a home, a página da oferta, os dados estruturados, o formulário de contato e as mensagens de
|
||||||
|
* WhatsApp importam daqui. Fica à parte de lib/site-content.ts, que reúne o texto de todas as páginas e não deve ir inteiro para
|
||||||
|
* o navegador: este módulo é pequeno, não tem dependência e não usa nada do Node, então componentes de cliente podem importá-lo.
|
||||||
|
*/
|
||||||
|
export const offerLabels = {
|
||||||
|
site: {
|
||||||
|
name: "Site que traz pedidos pelo WhatsApp",
|
||||||
|
whatsappSubject: "um site que traz pedidos pelo WhatsApp",
|
||||||
|
},
|
||||||
|
sistema: {
|
||||||
|
name: "Sistema para tirar a operação da planilha",
|
||||||
|
whatsappSubject: "um sistema para tirar a operação da planilha",
|
||||||
|
},
|
||||||
|
revisao: {
|
||||||
|
name: "Revisão de sistema que já existe",
|
||||||
|
whatsappSubject: "a revisão de um sistema que já existe",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
+44
-37
@@ -2,9 +2,9 @@ import sitemap from "../app/sitemap";
|
|||||||
import {
|
import {
|
||||||
buildBreadcrumbList,
|
buildBreadcrumbList,
|
||||||
buildCaseJsonLd,
|
buildCaseJsonLd,
|
||||||
|
buildOfferServicesJsonLd,
|
||||||
buildPageSocialMetadata,
|
buildPageSocialMetadata,
|
||||||
buildPersonJsonLd,
|
buildPersonJsonLd,
|
||||||
buildProfessionalServiceJsonLd,
|
|
||||||
buildProfilePageJsonLd,
|
buildProfilePageJsonLd,
|
||||||
buildServiceJsonLd,
|
buildServiceJsonLd,
|
||||||
buildWebSiteJsonLd,
|
buildWebSiteJsonLd,
|
||||||
@@ -29,7 +29,6 @@ it("uses one canonical identity across website, profile and service", () => {
|
|||||||
website: `${siteUrl}/#website`,
|
website: `${siteUrl}/#website`,
|
||||||
person: `${siteUrl}/#person`,
|
person: `${siteUrl}/#person`,
|
||||||
profilePage: `${siteUrl}/curriculo#profile-page`,
|
profilePage: `${siteUrl}/curriculo#profile-page`,
|
||||||
professionalService: `${siteUrl}/#professional-service`,
|
|
||||||
});
|
});
|
||||||
expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person });
|
expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person });
|
||||||
expect(buildProfilePageJsonLd()).toMatchObject({
|
expect(buildProfilePageJsonLd()).toMatchObject({
|
||||||
@@ -74,6 +73,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
|
|||||||
image: `${siteUrl}/edev.jpg`,
|
image: `${siteUrl}/edev.jpg`,
|
||||||
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
|
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
|
||||||
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
|
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
|
||||||
|
// só o LinkedIn: a conta do GitHub foi suspensa (404) e o Instagram fica fora por política
|
||||||
sameAs: ["https://www.linkedin.com/in/evandrodevbr/"],
|
sameAs: ["https://www.linkedin.com/in/evandrodevbr/"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds a canonical service with only the verified service area", () => {
|
it("builds a canonical service whose service area is the visible one: Garuva, Joinville, Santa Catarina and Brazil", () => {
|
||||||
const service = representativeService;
|
const service = representativeService;
|
||||||
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
||||||
|
|
||||||
@@ -96,49 +96,56 @@ it("builds a canonical service with only the verified service area", () => {
|
|||||||
areaServed: [
|
areaServed: [
|
||||||
{ "@type": "City", name: "Garuva" },
|
{ "@type": "City", name: "Garuva" },
|
||||||
{ "@type": "City", name: "Joinville" },
|
{ "@type": "City", name: "Joinville" },
|
||||||
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
|
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
|
||||||
|
{ "@type": "Country", name: "Brasil" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// a mesma área das três ofertas da home: um valor só em todo o JSON-LD
|
||||||
|
for (const offer of buildOfferServicesJsonLd()) {
|
||||||
|
expect(offer.areaServed).toEqual(buildServiceJsonLd(service).areaServed);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("describes the business as a ProfessionalService with the service area and the three offers as a catalog", () => {
|
it("describes the three offers as Service nodes tied to the person, with the service area and the page of each offer", () => {
|
||||||
const business = buildProfessionalServiceJsonLd();
|
const services = buildOfferServicesJsonLd();
|
||||||
|
|
||||||
expect(business).toMatchObject({
|
expect(services).toHaveLength(3);
|
||||||
"@type": "ProfessionalService",
|
// as mesmas três ofertas da home, com o mesmo nome e a página de cada uma (a revisão aponta para /ia)
|
||||||
"@id": SEO_IDS.professionalService,
|
expect(services.map(({ name }) => name)).toEqual([
|
||||||
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",
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
"Sistema para tirar a operação da planilha",
|
"Sistema para tirar a operação da planilha",
|
||||||
"Revisão de sistema que já existe, inclusive feito com IA",
|
"Revisão de sistema que já existe",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(services.map(({ url }) => url)).toEqual([
|
||||||
catalog.itemListElement.map(({ itemOffered }) => itemOffered.url),
|
`${siteUrl}/servicos/criacao-de-sites`,
|
||||||
).toEqual(serviceOffers.map(({ href }) => `${siteUrl}${href}`));
|
`${siteUrl}/servicos/engenharia-de-software`,
|
||||||
for (const offer of catalog.itemListElement) {
|
`${siteUrl}/ia`,
|
||||||
expect(offer["@type"]).toBe("Offer");
|
]);
|
||||||
expect(offer.itemOffered["@type"]).toBe("Service");
|
expect(services.map(({ url }) => url)).toEqual(
|
||||||
expect(offer.itemOffered.description.length).toBeGreaterThan(30);
|
serviceOffers.map(({ href }) => `${siteUrl}${href}`),
|
||||||
|
);
|
||||||
|
for (const service of services) {
|
||||||
|
expect(service["@type"]).toBe("Service");
|
||||||
|
expect(service.provider).toEqual({ "@id": SEO_IDS.person });
|
||||||
|
expect(service.areaServed.map(({ name }) => name)).toEqual([
|
||||||
|
"Garuva",
|
||||||
|
"Joinville",
|
||||||
|
"Santa Catarina",
|
||||||
|
"Brasil",
|
||||||
|
]);
|
||||||
|
expect(service.description.length).toBeGreaterThan(30);
|
||||||
}
|
}
|
||||||
// nada que o site não publique: sem preço, prazo, endereço de rua, telefone ou e-mail
|
// ids próprios e estáveis, que não repetem o `#service` que a página de cada serviço emite
|
||||||
expect(JSON.stringify(business)).not.toMatch(
|
const ids = services.map((service) => service["@id"]);
|
||||||
/price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i,
|
expect(new Set(ids).size).toBe(3);
|
||||||
|
expect(ids).toEqual([
|
||||||
|
`${siteUrl}/#service-site`,
|
||||||
|
`${siteUrl}/#service-sistema`,
|
||||||
|
`${siteUrl}/#service-revisao`,
|
||||||
|
]);
|
||||||
|
// política de docs/seo-geo-operations.md: nenhum tipo de negócio local e nada que o site não publique
|
||||||
|
expect(JSON.stringify(services)).not.toMatch(
|
||||||
|
/ProfessionalService|LocalBusiness|OfferCatalog|price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+29
-41
@@ -14,7 +14,6 @@ export const SEO_IDS = {
|
|||||||
website: `${siteUrl}/#website`,
|
website: `${siteUrl}/#website`,
|
||||||
person: `${siteUrl}/#person`,
|
person: `${siteUrl}/#person`,
|
||||||
profilePage: `${siteUrl}/curriculo#profile-page`,
|
profilePage: `${siteUrl}/curriculo#profile-page`,
|
||||||
professionalService: `${siteUrl}/#professional-service`,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function buildWebSiteJsonLd() {
|
export function buildWebSiteJsonLd() {
|
||||||
@@ -51,6 +50,7 @@ export function buildPersonJsonLd() {
|
|||||||
"@type": "Place",
|
"@type": "Place",
|
||||||
name: "Garuva, Santa Catarina, Brasil",
|
name: "Garuva, Santa Catarina, Brasil",
|
||||||
},
|
},
|
||||||
|
// perfis profissionais públicos e no ar; a conta do GitHub foi suspensa (404) e o Instagram fica fora por política
|
||||||
sameAs: ["https://www.linkedin.com/in/evandrodevbr/"],
|
sameAs: ["https://www.linkedin.com/in/evandrodevbr/"],
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
@@ -67,39 +67,34 @@ export function buildProfilePageJsonLd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* O negócio como `ProfessionalService`: onde atende (Garuva, Joinville, Santa Catarina e o Brasil, de forma remota) e as três ofertas
|
* Área de atendimento de todo nó `Service` (as ofertas da home e as páginas de serviço): a mesma do texto visível, "Garuva e
|
||||||
* 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
|
* Joinville (SC), atendimento remoto em todo o Brasil". Um valor só, para os dados estruturados não divergirem entre páginas.
|
||||||
* dados estruturados. Só o construtor: quem o coloca na página é a home.
|
|
||||||
*/
|
*/
|
||||||
export function buildProfessionalServiceJsonLd() {
|
const serviceAreaServed = [
|
||||||
return {
|
{ "@type": "City", name: "Garuva" },
|
||||||
"@type": "ProfessionalService",
|
{ "@type": "City", name: "Joinville" },
|
||||||
"@id": SEO_IDS.professionalService,
|
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
|
||||||
name: siteName,
|
{ "@type": "Country", name: "Brasil" },
|
||||||
url: siteUrl,
|
] as const;
|
||||||
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 },
|
* As três ofertas da home como nós `Service`, ligados à pessoa (`provider`) e com a área de atendimento (`serviceAreaServed`).
|
||||||
areaServed: [
|
* Nada de `ProfessionalService`, `LocalBusiness` ou catálogo de ofertas: a política
|
||||||
{ "@type": "City", name: "Garuva" },
|
* de docs/seo-geo-operations.md os exclui sem entidade ou local comprovado. Sem endereço, preço, prazo, telefone ou e-mail: o que
|
||||||
{ "@type": "City", name: "Joinville" },
|
* não está publicado no site não entra nos dados estruturados. Só o construtor: quem o coloca na página é a home.
|
||||||
{ "@type": "AdministrativeArea", name: "Santa Catarina" },
|
* O `url` é a página da oferta (a revisão abre /ia). O `@id` é da home, para não repetir o do nó que a página do serviço emite
|
||||||
{ "@type": "Country", name: "Brasil" },
|
* com outra descrição.
|
||||||
],
|
*/
|
||||||
hasOfferCatalog: {
|
export function buildOfferServicesJsonLd() {
|
||||||
"@type": "OfferCatalog",
|
return serviceOffers.map((offer) => ({
|
||||||
name: "Serviços de desenvolvimento de software",
|
"@type": "Service",
|
||||||
itemListElement: serviceOffers.map((offer) => ({
|
"@id": `${siteUrl}/#service-${offer.id}`,
|
||||||
"@type": "Offer",
|
name: offer.name,
|
||||||
itemOffered: {
|
description: offer.summary,
|
||||||
"@type": "Service",
|
url: `${siteUrl}${offer.href}`,
|
||||||
name: offer.name,
|
provider: { "@id": SEO_IDS.person },
|
||||||
description: offer.summary,
|
areaServed: serviceAreaServed,
|
||||||
url: `${siteUrl}${offer.href}`,
|
}));
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildServiceJsonLd(service: ServicePageContent) {
|
export function buildServiceJsonLd(service: ServicePageContent) {
|
||||||
@@ -112,14 +107,7 @@ export function buildServiceJsonLd(service: ServicePageContent) {
|
|||||||
name: service.navLabel,
|
name: service.navLabel,
|
||||||
description: service.metaDescription,
|
description: service.metaDescription,
|
||||||
provider: { "@id": SEO_IDS.person },
|
provider: { "@id": SEO_IDS.person },
|
||||||
areaServed: [
|
areaServed: serviceAreaServed,
|
||||||
{ "@type": "City", name: "Garuva" },
|
|
||||||
{ "@type": "City", name: "Joinville" },
|
|
||||||
{
|
|
||||||
"@type": "AdministrativeArea",
|
|
||||||
name: "Norte de Santa Catarina",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* Os dados reais só têm os quatro clientes da lista; o caminho do cliente fora da lista só existe aqui.
|
* 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"];
|
const LISTED = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||||
|
|
||||||
function loadWithClients(clientIds: readonly string[] | "all") {
|
function loadWithClients(clientIds: readonly string[] | "all") {
|
||||||
let loaded: typeof import("./site-content") | undefined;
|
let loaded: typeof import("./site-content") | undefined;
|
||||||
@@ -25,14 +25,14 @@ function loadWithClients(clientIds: readonly string[] | "all") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("clientCasesForListing: ordem", () => {
|
describe("clientCasesForListing: ordem", () => {
|
||||||
it("path: only listed clients -> the fixed order, the SEV last, whatever order the cases come in", () => {
|
it("path: only listed clients -> the fixed order, the SEV first, whatever order the cases come in", () => {
|
||||||
const { clientCasesForListing, clientCases } = loadWithClients(LISTED);
|
const { clientCasesForListing, clientCases } = loadWithClients(LISTED);
|
||||||
|
|
||||||
expect(clientCasesForListing.map(({ id }) => id)).toEqual(LISTED);
|
expect(clientCasesForListing.map(({ id }) => id)).toEqual(LISTED);
|
||||||
expect(clientCasesForListing).toHaveLength(clientCases.length);
|
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", () => {
|
it("path: a client that is not in the order list -> goes after all the listed ones", () => {
|
||||||
const { clientCasesForListing, caseStudies } = loadWithClients([
|
const { clientCasesForListing, caseStudies } = loadWithClients([
|
||||||
...LISTED,
|
...LISTED,
|
||||||
"gcc-installer",
|
"gcc-installer",
|
||||||
@@ -67,11 +67,11 @@ describe("clientCasesForListing: ordem", () => {
|
|||||||
expect(clientCasesForListing).toEqual([]);
|
expect(clientCasesForListing).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the real data -> the SEV is the last client, as the mosaic and /cases expect", () => {
|
it("path: the real data -> the SEV is the first client, as in the home showcase", () => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
const { clientCasesForListing } =
|
const { clientCasesForListing } =
|
||||||
jest.requireActual<typeof import("./site-content")>("./site-content");
|
jest.requireActual<typeof import("./site-content")>("./site-content");
|
||||||
|
|
||||||
expect(clientCasesForListing.at(-1)?.id).toBe("sev-modaintima");
|
expect(clientCasesForListing[0]?.id).toBe("sev-modaintima");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
caseStudies,
|
caseStudies,
|
||||||
clientCases,
|
clientCases,
|
||||||
clientCasesForListing,
|
clientCasesForListing,
|
||||||
|
generalFirstStep,
|
||||||
getRelatedCases,
|
getRelatedCases,
|
||||||
getRelatedServices,
|
getRelatedServices,
|
||||||
|
getServiceOffer,
|
||||||
isClientCase,
|
isClientCase,
|
||||||
labCases,
|
labCases,
|
||||||
pageUpdatedAt,
|
pageUpdatedAt,
|
||||||
@@ -93,12 +95,18 @@ it("mentions the region and a benefit in the title and description of every serv
|
|||||||
expect(new Set(descriptions).size).toBe(descriptions.length);
|
expect(new Set(descriptions).size).toBe(descriptions.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps each service to its client cases, with the SEV only where it is the proof: the systems service", () => {
|
it("maps each service to its client cases, with the SEV as proof of both offers: the site and the system", () => {
|
||||||
|
// a Lavação abre a página, a SEV é o segundo trabalho e os dois sites institucionais vão para o bloco menor
|
||||||
expect(getService("criacao-de-sites").relatedCaseIds).toEqual([
|
expect(getService("criacao-de-sites").relatedCaseIds).toEqual([
|
||||||
"lavacao-047",
|
"lavacao-047",
|
||||||
|
"sev-modaintima",
|
||||||
"sc-plus",
|
"sc-plus",
|
||||||
"as-servicos",
|
"as-servicos",
|
||||||
]);
|
]);
|
||||||
|
expect(getService("criacao-de-sites").alsoCases).toEqual({
|
||||||
|
title: "Também faço sites institucionais",
|
||||||
|
caseIds: ["sc-plus", "as-servicos"],
|
||||||
|
});
|
||||||
expect(getService("engenharia-de-software").relatedCaseIds).toEqual([
|
expect(getService("engenharia-de-software").relatedCaseIds).toEqual([
|
||||||
"sev-modaintima",
|
"sev-modaintima",
|
||||||
"lavacao-047",
|
"lavacao-047",
|
||||||
@@ -111,7 +119,12 @@ it("maps each service to its client cases, with the SEV only where it is the pro
|
|||||||
servicePages
|
servicePages
|
||||||
.filter(({ relatedCaseIds }) => relatedCaseIds.includes("sev-modaintima"))
|
.filter(({ relatedCaseIds }) => relatedCaseIds.includes("sev-modaintima"))
|
||||||
.map(({ slug }) => slug),
|
.map(({ slug }) => slug),
|
||||||
).toEqual(["engenharia-de-software"]);
|
).toEqual(["criacao-de-sites", "engenharia-de-software"]);
|
||||||
|
// o bloco menor só cita cases que o serviço já relaciona
|
||||||
|
for (const { relatedCaseIds, alsoCases } of servicePages) {
|
||||||
|
for (const id of alsoCases?.caseIds ?? [])
|
||||||
|
expect(relatedCaseIds).toContain(id);
|
||||||
|
}
|
||||||
// nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo
|
// nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo
|
||||||
expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]);
|
expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]);
|
||||||
expect(getService("arquitetura-de-software").caseRelevance).toEqual({});
|
expect(getService("arquitetura-de-software").caseRelevance).toEqual({});
|
||||||
@@ -195,12 +208,12 @@ it("gives each service without a client cover a flow diagram instead, and never
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists the client cases for the mosaic and /cases with the SEV last, never first", () => {
|
it("lists the client cases for the mosaic and /cases in the order of the home showcase, the SEV first", () => {
|
||||||
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
|
expect(clientCasesForListing.map(({ id }) => id)).toEqual([
|
||||||
|
"sev-modaintima",
|
||||||
"lavacao-047",
|
"lavacao-047",
|
||||||
"sc-plus",
|
"sc-plus",
|
||||||
"as-servicos",
|
"as-servicos",
|
||||||
"sev-modaintima",
|
|
||||||
]);
|
]);
|
||||||
expect(clientCasesForListing).toHaveLength(clientCases.length);
|
expect(clientCasesForListing).toHaveLength(clientCases.length);
|
||||||
});
|
});
|
||||||
@@ -222,7 +235,12 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
|
|||||||
expect(serviceOffers.map(({ name }) => name)).toEqual([
|
expect(serviceOffers.map(({ name }) => name)).toEqual([
|
||||||
"Site que traz pedidos pelo WhatsApp",
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
"Sistema para tirar a operação da planilha",
|
"Sistema para tirar a operação da planilha",
|
||||||
"Revisão de sistema que já existe, inclusive feito com IA",
|
"Revisão de sistema que já existe",
|
||||||
|
]);
|
||||||
|
expect(serviceOffers.map(({ whatsappSubject }) => whatsappSubject)).toEqual([
|
||||||
|
"um site que traz pedidos pelo WhatsApp",
|
||||||
|
"um sistema para tirar a operação da planilha",
|
||||||
|
"a revisão de um sistema que já existe",
|
||||||
]);
|
]);
|
||||||
expect(serviceOffers.map(({ href }) => href)).toEqual([
|
expect(serviceOffers.map(({ href }) => href)).toEqual([
|
||||||
"/servicos/criacao-de-sites",
|
"/servicos/criacao-de-sites",
|
||||||
@@ -232,6 +250,13 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
|
|||||||
for (const offer of serviceOffers) {
|
for (const offer of serviceOffers) {
|
||||||
expect(offer.deliverables.length).toBeGreaterThanOrEqual(3);
|
expect(offer.deliverables.length).toBeGreaterThanOrEqual(3);
|
||||||
expect(offer.deliverables.length).toBeLessThanOrEqual(4);
|
expect(offer.deliverables.length).toBeLessThanOrEqual(4);
|
||||||
|
expect(offer.firstStep).toMatch(
|
||||||
|
/^Primeira conversa .* proposta por escrito\.$/,
|
||||||
|
);
|
||||||
|
// a frase geral da home não contradiz a da oferta: as duas terminam igual
|
||||||
|
expect(offer.firstStep.endsWith(generalFirstStep.split(". ")[1])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
offer.proof.caseIds.every((id) => caseStudies.some((c) => c.id === id)),
|
offer.proof.caseIds.every((id) => caseStudies.some((c) => c.id === id)),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -240,6 +265,7 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
|
|||||||
offer.name,
|
offer.name,
|
||||||
offer.summary,
|
offer.summary,
|
||||||
...offer.deliverables,
|
...offer.deliverables,
|
||||||
|
offer.firstStep,
|
||||||
offer.proof.note ?? "",
|
offer.proof.note ?? "",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/);
|
expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/);
|
||||||
@@ -254,6 +280,48 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
|
|||||||
expect(serviceOffers[2].proof.caseIds).toEqual([]);
|
expect(serviceOffers[2].proof.caseIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses one name per offer: the service page, the breadcrumb label and the WhatsApp subject match the offer", () => {
|
||||||
|
for (const offer of serviceOffers) {
|
||||||
|
const service = servicePages.find(({ slug }) => offer.href.endsWith(slug));
|
||||||
|
// a revisão abre /ia, que não é uma página de serviço
|
||||||
|
if (!service) {
|
||||||
|
expect(offer.id).toBe("revisao");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(service.navLabel).toBe(offer.name);
|
||||||
|
expect(service.headline).toBe(offer.name);
|
||||||
|
expect(service.whatsappSubject).toBe(offer.whatsappSubject);
|
||||||
|
expect(getServiceOffer(service.slug)).toBe(offer);
|
||||||
|
}
|
||||||
|
expect(getServiceOffer("consultoria-nextjs")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every service a short name and a lowercase WhatsApp subject with its article, and no two names alike", () => {
|
||||||
|
expect(servicePages.map(({ navLabel }) => navLabel)).toEqual([
|
||||||
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
|
"Sistema para tirar a operação da planilha",
|
||||||
|
"Consultoria em Next.js",
|
||||||
|
"Aplicativos desktop",
|
||||||
|
"Integração de equipamentos",
|
||||||
|
"Diagnóstico de arquitetura",
|
||||||
|
]);
|
||||||
|
for (const service of servicePages) {
|
||||||
|
expect(service.whatsappSubject).toMatch(/^(um|uma|a) [a-zà-ú]/);
|
||||||
|
expect(service.whatsappSubject).not.toMatch(/[.!?]$/);
|
||||||
|
}
|
||||||
|
expect(getService("consultoria-nextjs").whatsappSubject).toBe(
|
||||||
|
"uma consultoria em Next.js",
|
||||||
|
);
|
||||||
|
const names = servicePages.map(({ navLabel }) => navLabel);
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the systems service in terms of the pain: spreadsheets and rework", () => {
|
||||||
|
expect(getService("engenharia-de-software").answer).toMatch(
|
||||||
|
/planilhas.*retrabalho/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("writes 2 or 3 capabilities for every client case, without numbers, and none for the laboratory", () => {
|
it("writes 2 or 3 capabilities for every client case, without numbers, and none for the laboratory", () => {
|
||||||
for (const caseStudy of clientCases) {
|
for (const caseStudy of clientCases) {
|
||||||
expect(caseStudy.capabilities?.length).toBeGreaterThanOrEqual(2);
|
expect(caseStudy.capabilities?.length).toBeGreaterThanOrEqual(2);
|
||||||
@@ -323,7 +391,9 @@ it("derives the exact related service set from the existing relationship", () =>
|
|||||||
expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([
|
expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([
|
||||||
"integracao-hardware-software-iot",
|
"integracao-hardware-software-iot",
|
||||||
]);
|
]);
|
||||||
|
// a SEV liga à oferta de site e à de sistema, nesta ordem
|
||||||
expect(getRelatedServices("sev-modaintima").map(({ slug }) => slug)).toEqual([
|
expect(getRelatedServices("sev-modaintima").map(({ slug }) => slug)).toEqual([
|
||||||
|
"criacao-de-sites",
|
||||||
"engenharia-de-software",
|
"engenharia-de-software",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
@@ -373,7 +443,7 @@ it("writes one sentence on why each related case matters to its service", () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => {
|
it("cites the Apolo Energia experience, from 2022, only on the systems, IoT and architecture services", () => {
|
||||||
const apolo = portfolioData.experiences.find(
|
const apolo = portfolioData.experiences.find(
|
||||||
({ id }) => id === "apolo-energia",
|
({ id }) => id === "apolo-energia",
|
||||||
);
|
);
|
||||||
@@ -383,6 +453,7 @@ it("cites the Apolo Energia experience, from 2022, only on the IoT and architect
|
|||||||
.filter(({ experienceNote }) => experienceNote)
|
.filter(({ experienceNote }) => experienceNote)
|
||||||
.map(({ slug }) => slug);
|
.map(({ slug }) => slug);
|
||||||
expect(withNote).toEqual([
|
expect(withNote).toEqual([
|
||||||
|
"engenharia-de-software",
|
||||||
"integracao-hardware-software-iot",
|
"integracao-hardware-software-iot",
|
||||||
"arquitetura-de-software",
|
"arquitetura-de-software",
|
||||||
]);
|
]);
|
||||||
@@ -390,12 +461,24 @@ it("cites the Apolo Energia experience, from 2022, only on the IoT and architect
|
|||||||
const note = getService(slug).experienceNote ?? "";
|
const note = getService(slug).experienceNote ?? "";
|
||||||
expect(note).toMatch(/Apolo Energia/);
|
expect(note).toMatch(/Apolo Energia/);
|
||||||
expect(note).toMatch(/desde 2022/i);
|
expect(note).toMatch(/desde 2022/i);
|
||||||
expect(note).toMatch(/sistemas internos/i);
|
|
||||||
expect(note).toMatch(/aplicativos/i);
|
|
||||||
expect(note).toMatch(/APIs/);
|
expect(note).toMatch(/APIs/);
|
||||||
// sem link e sem detalhe de operação do cliente
|
// sem link e sem detalhe de operação do cliente
|
||||||
expect(note).not.toMatch(/https?:|www\.|\.com/i);
|
expect(note).not.toMatch(/https?:|www\.|\.com/i);
|
||||||
}
|
}
|
||||||
|
for (const slug of [
|
||||||
|
"integracao-hardware-software-iot",
|
||||||
|
"arquitetura-de-software",
|
||||||
|
]) {
|
||||||
|
const note = getService(slug).experienceNote ?? "";
|
||||||
|
expect(note).toMatch(/sistemas internos/i);
|
||||||
|
expect(note).toMatch(/aplicativos/i);
|
||||||
|
}
|
||||||
|
// o sistema diz o que o currículo diz da Apolo: aplicações web e mobile para rotinas internas, APIs e painéis de operação
|
||||||
|
const systemsNote = getService("engenharia-de-software").experienceNote ?? "";
|
||||||
|
expect(systemsNote).toMatch(/aplicações web e mobile para rotinas internas/);
|
||||||
|
expect(systemsNote).toMatch(/painéis de operação/);
|
||||||
|
expect(apolo?.description).toMatch(/aplicações Web e Mobile/);
|
||||||
|
expect(apolo?.description).toMatch(/rotinas internas/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
|
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
|
||||||
@@ -449,6 +532,7 @@ it("keeps AS Serviços without a public site and states the author's participati
|
|||||||
it("keeps site-content and the modules it imports free of Node built-ins, so client components can import it", () => {
|
it("keeps site-content and the modules it imports free of Node built-ins, so client components can import it", () => {
|
||||||
for (const file of [
|
for (const file of [
|
||||||
"site-content",
|
"site-content",
|
||||||
|
"offer-labels",
|
||||||
"portfolio-data",
|
"portfolio-data",
|
||||||
"proof",
|
"proof",
|
||||||
"project-health",
|
"project-health",
|
||||||
|
|||||||
+59
-19
@@ -1,3 +1,4 @@
|
|||||||
|
import { offerLabels } from "./offer-labels";
|
||||||
import { portfolio } from "./portfolio-data";
|
import { portfolio } from "./portfolio-data";
|
||||||
import { isClientProject } from "./proof";
|
import { isClientProject } from "./proof";
|
||||||
|
|
||||||
@@ -25,7 +26,10 @@ export const buttonLabels = {
|
|||||||
|
|
||||||
export interface ServicePageContent {
|
export interface ServicePageContent {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** Nome curto do serviço: o mesmo na trilha, nos títulos, nos links e nos dados estruturados. Nas ofertas da home é o nome da oferta. */
|
||||||
navLabel: string;
|
navLabel: string;
|
||||||
|
/** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */
|
||||||
|
whatsappSubject: string;
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
/** Título para busca, separado do h1: até 43 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
|
/** Título para busca, separado do h1: até 43 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
|
||||||
metaTitle: string;
|
metaTitle: string;
|
||||||
@@ -36,6 +40,7 @@ export interface ServicePageContent {
|
|||||||
intro: string;
|
intro: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
audience: string[];
|
audience: string[];
|
||||||
|
/** "O que podemos entregar". Nas páginas que explicam uma oferta da home a seção não aparece: vale o "O que você recebe" da oferta (`serviceOffers`), uma lista de entregáveis só. */
|
||||||
outcomes: string[];
|
outcomes: string[];
|
||||||
process: string[];
|
process: string[];
|
||||||
examples: string[];
|
examples: string[];
|
||||||
@@ -43,6 +48,8 @@ export interface ServicePageContent {
|
|||||||
stack: string[];
|
stack: string[];
|
||||||
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
|
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
|
||||||
relatedCaseIds: string[];
|
relatedCaseIds: string[];
|
||||||
|
/** Cases relacionados que saem de "Onde já apliquei isso" e vão para um bloco menor com este título, sem capa (ids de `relatedCaseIds`). */
|
||||||
|
alsoCases?: { title: string; caseIds: string[] };
|
||||||
/** Capa do topo: o case (um dos relacionados) e o aparelho da captura. Sem ela, o topo mostra o esquema `diagram`. */
|
/** 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 };
|
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. */
|
/** Esquema de fluxo do topo, para o serviço sem case com captura: nunca leva dado real nem logo de cliente. */
|
||||||
@@ -101,12 +108,13 @@ export interface CaseStudyContent {
|
|||||||
export const servicePages: ServicePageContent[] = [
|
export const servicePages: ServicePageContent[] = [
|
||||||
{
|
{
|
||||||
slug: "criacao-de-sites",
|
slug: "criacao-de-sites",
|
||||||
navLabel: "Sites profissionais",
|
navLabel: offerLabels.site.name,
|
||||||
|
whatsappSubject: offerLabels.site.whatsappSubject,
|
||||||
eyebrow: "Presença digital",
|
eyebrow: "Presença digital",
|
||||||
metaTitle: "Criação de sites em Joinville e SC",
|
metaTitle: "Criação de sites em Joinville e SC",
|
||||||
metaDescription:
|
metaDescription:
|
||||||
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
|
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
|
||||||
headline: "Site que traz pedidos pelo WhatsApp",
|
headline: offerLabels.site.name,
|
||||||
answer:
|
answer:
|
||||||
"Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
|
"Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
|
||||||
intro:
|
intro:
|
||||||
@@ -138,12 +146,19 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
"Metadados, URLs canônicas, sitemap e dados estruturados ajudam os buscadores a entender o site.",
|
"Metadados, URLs canônicas, sitemap e dados estruturados ajudam os buscadores a entender o site.",
|
||||||
],
|
],
|
||||||
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
|
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
|
||||||
relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"],
|
// a Lavação abre a página (capa), a SEV é o segundo trabalho e os dois sites institucionais ficam no bloco menor
|
||||||
|
relatedCaseIds: ["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"],
|
||||||
heroCase: { id: "lavacao-047", device: "desktop" },
|
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||||
|
alsoCases: {
|
||||||
|
title: "Também faço sites institucionais",
|
||||||
|
caseIds: ["sc-plus", "as-servicos"],
|
||||||
|
},
|
||||||
updatedAt: "2026-10-14",
|
updatedAt: "2026-10-14",
|
||||||
caseRelevance: {
|
caseRelevance: {
|
||||||
"lavacao-047":
|
"lavacao-047":
|
||||||
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
|
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
|
||||||
|
"sev-modaintima":
|
||||||
|
"Um catálogo por tamanho com pedido pelo WhatsApp, e um painel para a loja cuidar de produtos e estoque.",
|
||||||
"sc-plus":
|
"sc-plus":
|
||||||
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
|
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
|
||||||
"as-servicos":
|
"as-servicos":
|
||||||
@@ -169,14 +184,15 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "engenharia-de-software",
|
slug: "engenharia-de-software",
|
||||||
navLabel: "Sistemas web e aplicativos mobile",
|
navLabel: offerLabels.sistema.name,
|
||||||
|
whatsappSubject: offerLabels.sistema.whatsappSubject,
|
||||||
eyebrow: "Engenharia de software",
|
eyebrow: "Engenharia de software",
|
||||||
metaTitle: "Sistemas web e aplicativos sob medida",
|
metaTitle: "Sistemas web e aplicativos sob medida",
|
||||||
metaDescription:
|
metaDescription:
|
||||||
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
|
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
|
||||||
headline: "Sistema para tirar a operação da planilha",
|
headline: offerLabels.sistema.name,
|
||||||
answer:
|
answer:
|
||||||
"Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.",
|
"Quando a operação vive em planilhas, mensagens e retrabalho, desenvolvo um sistema web ou um aplicativo mobile feito para a sua equipe. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções e a primeira entrega útil.",
|
||||||
intro:
|
intro:
|
||||||
"Organize a operação com um sistema web ou aplicativo mobile sob medida. Conecte ferramentas, reduza tarefas repetidas ou melhore um sistema que já existe.",
|
"Organize a operação com um sistema web ou aplicativo mobile sob medida. Conecte ferramentas, reduza tarefas repetidas ou melhore um sistema que já existe.",
|
||||||
audience: [
|
audience: [
|
||||||
@@ -215,6 +231,8 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
"lavacao-047":
|
"lavacao-047":
|
||||||
"Um painel em que o dono altera preços, textos e fotos, e a mudança aparece no site sem nova publicação.",
|
"Um painel em que o dono altera preços, textos e fotos, e a mudança aparece no site sem nova publicação.",
|
||||||
},
|
},
|
||||||
|
experienceNote:
|
||||||
|
"Desde 2022 trabalho com isso na Apolo Energia: 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.",
|
||||||
faq: [
|
faq: [
|
||||||
{
|
{
|
||||||
question: "Preciso saber qual tecnologia usar?",
|
question: "Preciso saber qual tecnologia usar?",
|
||||||
@@ -235,7 +253,8 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "consultoria-nextjs",
|
slug: "consultoria-nextjs",
|
||||||
navLabel: "Melhorias em sites e sistemas Next.js",
|
navLabel: "Consultoria em Next.js",
|
||||||
|
whatsappSubject: "uma consultoria em Next.js",
|
||||||
eyebrow: "Consultoria Next.js",
|
eyebrow: "Consultoria Next.js",
|
||||||
metaTitle: "Consultoria Next.js para site ou sistema",
|
metaTitle: "Consultoria Next.js para site ou sistema",
|
||||||
metaDescription:
|
metaDescription:
|
||||||
@@ -301,7 +320,8 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "tauri-rust",
|
slug: "tauri-rust",
|
||||||
navLabel: "Aplicativos desktop para Windows e Linux",
|
navLabel: "Aplicativos desktop",
|
||||||
|
whatsappSubject: "um aplicativo desktop",
|
||||||
eyebrow: "Aplicativos desktop",
|
eyebrow: "Aplicativos desktop",
|
||||||
metaTitle: "Aplicativos desktop para Windows e Linux",
|
metaTitle: "Aplicativos desktop para Windows e Linux",
|
||||||
metaDescription:
|
metaDescription:
|
||||||
@@ -370,7 +390,8 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "integracao-hardware-software-iot",
|
slug: "integracao-hardware-software-iot",
|
||||||
navLabel: "Integração de equipamentos e sistemas",
|
navLabel: "Integração de equipamentos",
|
||||||
|
whatsappSubject: "uma integração de equipamentos",
|
||||||
eyebrow: "Hardware, software e IoT",
|
eyebrow: "Hardware, software e IoT",
|
||||||
metaTitle: "Integração de equipamentos e sistemas (IoT)",
|
metaTitle: "Integração de equipamentos e sistemas (IoT)",
|
||||||
metaDescription:
|
metaDescription:
|
||||||
@@ -436,7 +457,8 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "arquitetura-de-software",
|
slug: "arquitetura-de-software",
|
||||||
navLabel: "Diagnóstico e arquitetura de software",
|
navLabel: "Diagnóstico de arquitetura",
|
||||||
|
whatsappSubject: "um diagnóstico de arquitetura",
|
||||||
eyebrow: "Diagnóstico e decisão",
|
eyebrow: "Diagnóstico e decisão",
|
||||||
metaTitle: "Diagnóstico e arquitetura de software",
|
metaTitle: "Diagnóstico e arquitetura de software",
|
||||||
metaDescription:
|
metaDescription:
|
||||||
@@ -816,14 +838,14 @@ export const labCases = caseStudies.filter(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordem dos clientes na lista de /cases e no mosaico de /servicos: a SEV Moda Íntima, que já abre a página de sistemas
|
* Ordem dos clientes na lista de /cases e no mosaico de /servicos: a mesma da vitrine da home (SHOWCASE em
|
||||||
* 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.
|
* lib/project-showcase.ts), com a SEV Moda Íntima primeiro. Cliente fora desta lista vai para o fim.
|
||||||
*/
|
*/
|
||||||
const clientListingOrder = [
|
const clientListingOrder = [
|
||||||
|
"sev-modaintima",
|
||||||
"lavacao-047",
|
"lavacao-047",
|
||||||
"sc-plus",
|
"sc-plus",
|
||||||
"as-servicos",
|
"as-servicos",
|
||||||
"sev-modaintima",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function listingRank({ id }: { id: string }) {
|
function listingRank({ id }: { id: string }) {
|
||||||
@@ -848,18 +870,28 @@ export const pageUpdatedAt = {
|
|||||||
|
|
||||||
export interface ServiceOffer {
|
export interface ServiceOffer {
|
||||||
id: "site" | "sistema" | "revisao";
|
id: "site" | "sistema" | "revisao";
|
||||||
/** Nome da oferta, o mesmo da home. */
|
/** Nome da oferta, o mesmo da home, da página da oferta, dos dados estruturados e do WhatsApp. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */
|
||||||
|
whatsappSubject: string;
|
||||||
/** Uma frase sobre a oferta (descrição dos dados estruturados). */
|
/** Uma frase sobre a oferta (descrição dos dados estruturados). */
|
||||||
summary: string;
|
summary: string;
|
||||||
/** Página que explica a oferta. */
|
/** Página que explica a oferta. */
|
||||||
href: string;
|
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. */
|
/** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. A home e a página da oferta mostram esta lista. */
|
||||||
deliverables: string[];
|
deliverables: string[];
|
||||||
|
/** Primeiro passo: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). A página da oferta mostra esta frase; a home, que serve às três, mostra `generalFirstStep`. */
|
||||||
|
firstStep: string;
|
||||||
/** Onde ver a oferta funcionando: o texto que abre a linha, os cases (links) e uma observação opcional. */
|
/** 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 };
|
proof: { label: string; caseIds: string[]; note?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** O que vem depois da primeira conversa: o mesmo em cada oferta e na frase geral da home. */
|
||||||
|
const FIRST_STEP_NEXT = "Depois, escopo e proposta por escrito.";
|
||||||
|
|
||||||
|
/** O primeiro passo da home, que vale para as três ofertas: igual ao de cada uma, só sem dizer o que se entende na conversa. */
|
||||||
|
export const generalFirstStep = `Primeira conversa para entender o seu caso. ${FIRST_STEP_NEXT}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* 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.
|
* profissional ou a página /ia já sustentam; sem prazo nem preço.
|
||||||
@@ -867,7 +899,7 @@ export interface ServiceOffer {
|
|||||||
export const serviceOffers: ServiceOffer[] = [
|
export const serviceOffers: ServiceOffer[] = [
|
||||||
{
|
{
|
||||||
id: "site",
|
id: "site",
|
||||||
name: "Site que traz pedidos pelo WhatsApp",
|
...offerLabels.site,
|
||||||
summary:
|
summary:
|
||||||
"Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho.",
|
"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",
|
href: "/servicos/criacao-de-sites",
|
||||||
@@ -877,6 +909,7 @@ export const serviceOffers: ServiceOffer[] = [
|
|||||||
"Painel para você mudar preços, textos e fotos sem pedir a ninguém.",
|
"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.",
|
"Leitura boa no celular e informações organizadas para o Google.",
|
||||||
],
|
],
|
||||||
|
firstStep: `Primeira conversa para entender o problema. ${FIRST_STEP_NEXT}`,
|
||||||
proof: {
|
proof: {
|
||||||
label: "Veja funcionando",
|
label: "Veja funcionando",
|
||||||
caseIds: ["lavacao-047", "sev-modaintima"],
|
caseIds: ["lavacao-047", "sev-modaintima"],
|
||||||
@@ -884,7 +917,7 @@ export const serviceOffers: ServiceOffer[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sistema",
|
id: "sistema",
|
||||||
name: "Sistema para tirar a operação da planilha",
|
...offerLabels.sistema,
|
||||||
summary:
|
summary:
|
||||||
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe.",
|
"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",
|
href: "/servicos/engenharia-de-software",
|
||||||
@@ -894,6 +927,7 @@ export const serviceOffers: ServiceOffer[] = [
|
|||||||
"Acesso por perfil: cada pessoa vê só o que precisa.",
|
"Acesso por perfil: cada pessoa vê só o que precisa.",
|
||||||
"Uma primeira versão com as funções prioritárias, ampliada em etapas.",
|
"Uma primeira versão com as funções prioritárias, ampliada em etapas.",
|
||||||
],
|
],
|
||||||
|
firstStep: `Primeira conversa para entender a operação. ${FIRST_STEP_NEXT}`,
|
||||||
proof: {
|
proof: {
|
||||||
label: "Veja funcionando",
|
label: "Veja funcionando",
|
||||||
caseIds: ["sev-modaintima"],
|
caseIds: ["sev-modaintima"],
|
||||||
@@ -902,9 +936,9 @@ export const serviceOffers: ServiceOffer[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "revisao",
|
id: "revisao",
|
||||||
name: "Revisão de sistema que já existe, inclusive feito com IA",
|
...offerLabels.revisao,
|
||||||
summary:
|
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.",
|
"Avaliação de um sistema que já existe, inclusive feito com IA: teste das falhas de acesso a dados e separação do que dá para aproveitar do que é melhor refazer.",
|
||||||
href: "/ia",
|
href: "/ia",
|
||||||
deliverables: [
|
deliverables: [
|
||||||
"As falhas de acesso a dados: quem consegue ver ou alterar o dado de quem.",
|
"As falhas de acesso a dados: quem consegue ver ou alterar o dado de quem.",
|
||||||
@@ -912,6 +946,7 @@ export const serviceOffers: ServiceOffer[] = [
|
|||||||
"O que é melhor refazer, com o motivo de cada parte.",
|
"O que é melhor refazer, com o motivo de cada parte.",
|
||||||
"Um orçamento por escrito para pôr o sistema em produção.",
|
"Um orçamento por escrito para pôr o sistema em produção.",
|
||||||
],
|
],
|
||||||
|
firstStep: `Primeira conversa para entender o app e o que ele precisa fazer. ${FIRST_STEP_NEXT}`,
|
||||||
proof: {
|
proof: {
|
||||||
label: "Veja como funciona",
|
label: "Veja como funciona",
|
||||||
caseIds: [],
|
caseIds: [],
|
||||||
@@ -930,6 +965,11 @@ export function getServicePage(slug: string) {
|
|||||||
return servicePages.find((service) => service.slug === slug);
|
return servicePages.find((service) => service.slug === slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A oferta que a página de serviço explica; os serviços que não são oferta (e a revisão, que abre /ia) não têm. */
|
||||||
|
export function getServiceOffer(slug: string) {
|
||||||
|
return serviceOffers.find(({ href }) => href === `/servicos/${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Cases na ordem de `caseIds`, com os de cliente à frente dos de laboratório. */
|
/** Cases na ordem de `caseIds`, com os de cliente à frente dos de laboratório. */
|
||||||
export function getRelatedCases(caseIds: string[]) {
|
export function getRelatedCases(caseIds: string[]) {
|
||||||
const cases = caseIds.flatMap((id) => {
|
const cases = caseIds.flatMap((id) => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const contexts: WhatsAppContexts = {
|
const contexts: WhatsAppContexts = {
|
||||||
services: { "criacao-de-sites": "Sites profissionais" },
|
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
|
||||||
cases: { "sev-modaintima": "SEV Moda Íntima", vazio: "" },
|
cases: { "sev-modaintima": "SEV Moda Íntima", vazio: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+42
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import { servicePages } from "./site-content";
|
||||||
import {
|
import {
|
||||||
AI_REVIEW_SUBJECT,
|
AI_REVIEW_SUBJECT,
|
||||||
buildWhatsAppUrl,
|
buildWhatsAppUrl,
|
||||||
@@ -5,6 +6,7 @@ import {
|
|||||||
HOME_SUBJECT,
|
HOME_SUBJECT,
|
||||||
headerOpensWhatsApp,
|
headerOpensWhatsApp,
|
||||||
isServiceOrCasePath,
|
isServiceOrCasePath,
|
||||||
|
OFFER_SUBJECTS,
|
||||||
RESUME_SUBJECT,
|
RESUME_SUBJECT,
|
||||||
SERVICES_SUBJECT,
|
SERVICES_SUBJECT,
|
||||||
similarProjectSubject,
|
similarProjectSubject,
|
||||||
@@ -13,15 +15,39 @@ import {
|
|||||||
} from "./whatsapp";
|
} from "./whatsapp";
|
||||||
|
|
||||||
const contexts: WhatsAppContexts = {
|
const contexts: WhatsAppContexts = {
|
||||||
services: { "criacao-de-sites": "Sites profissionais" },
|
// o whatsappSubject do serviço, como app/layout.tsx o monta: uma frase em minúsculas, e não o nome do serviço
|
||||||
|
services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" },
|
||||||
cases: { "sev-modaintima": "SEV Moda Íntima" },
|
cases: { "sev-modaintima": "SEV Moda Íntima" },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("whatsAppSubjectForPath", () => {
|
describe("whatsAppSubjectForPath", () => {
|
||||||
it("cites the service on /servicos/<slug>", () => {
|
it("cites the service on /servicos/<slug> with its whatsappSubject, a lowercase phrase, so the message reads as a sentence", () => {
|
||||||
expect(whatsAppSubjectForPath("/servicos/criacao-de-sites", contexts)).toBe(
|
const subject = whatsAppSubjectForPath(
|
||||||
"Sites profissionais",
|
"/servicos/criacao-de-sites",
|
||||||
|
contexts,
|
||||||
);
|
);
|
||||||
|
expect(subject).toBe("um site que traz pedidos pelo WhatsApp");
|
||||||
|
expect(`Quero conversar sobre ${subject}.`).not.toMatch(
|
||||||
|
/sobre Sites profissionais/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a whatsappSubject, in lowercase and with no final period, for every service page and offer of site-content", () => {
|
||||||
|
expect(servicePages.length).toBeGreaterThan(0);
|
||||||
|
for (const { slug, navLabel, whatsappSubject } of servicePages) {
|
||||||
|
// não é o nome do serviço (que tem inicial maiúscula): é uma frase que entra depois de "sobre"
|
||||||
|
expect(whatsappSubject).toMatch(/^[a-zà-ú].*[^.]$/);
|
||||||
|
expect(whatsappSubject).not.toBe(navLabel);
|
||||||
|
expect(
|
||||||
|
whatsAppSubjectForPath(`/servicos/${slug}`, {
|
||||||
|
services: { [slug]: whatsappSubject },
|
||||||
|
cases: {},
|
||||||
|
}),
|
||||||
|
).toBe(whatsappSubject);
|
||||||
|
}
|
||||||
|
for (const subject of Object.values(OFFER_SUBJECTS)) {
|
||||||
|
expect(subject).toMatch(/^[a-zà-ú].*[^.]$/);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cites the case on /cases/<slug>, with the phrase every page uses for a similar project", () => {
|
it("cites the case on /cases/<slug>, with the phrase every page uses for a similar project", () => {
|
||||||
@@ -40,7 +66,17 @@ describe("whatsAppSubjectForPath", () => {
|
|||||||
expect(whatsAppSubjectForPath("/servicos", contexts)).toBe(
|
expect(whatsAppSubjectForPath("/servicos", contexts)).toBe(
|
||||||
SERVICES_SUBJECT,
|
SERVICES_SUBJECT,
|
||||||
);
|
);
|
||||||
expect(SERVICES_SUBJECT).toBe("Sites, sistemas e aplicativos");
|
expect(SERVICES_SUBJECT).toBe("sites, sistemas e aplicativos");
|
||||||
|
// nenhum assunto fixo começa em maiúscula: "Quero conversar sobre Sites, sistemas..." era o erro
|
||||||
|
for (const subject of [
|
||||||
|
AI_REVIEW_SUBJECT,
|
||||||
|
HOME_SUBJECT,
|
||||||
|
CASES_SUBJECT,
|
||||||
|
SERVICES_SUBJECT,
|
||||||
|
RESUME_SUBJECT,
|
||||||
|
]) {
|
||||||
|
expect(subject).toMatch(/^[a-zà-ú]/);
|
||||||
|
}
|
||||||
expect(whatsAppSubjectForPath("/curriculo", contexts)).toBe(RESUME_SUBJECT);
|
expect(whatsAppSubjectForPath("/curriculo", contexts)).toBe(RESUME_SUBJECT);
|
||||||
// sem contextos, a página-índice continua falando de si
|
// sem contextos, a página-índice continua falando de si
|
||||||
expect(whatsAppSubjectForPath("/cases/")).toBe(CASES_SUBJECT);
|
expect(whatsAppSubjectForPath("/cases/")).toBe(CASES_SUBJECT);
|
||||||
@@ -55,7 +91,7 @@ describe("whatsAppSubjectForPath", () => {
|
|||||||
it("ignores a trailing slash", () => {
|
it("ignores a trailing slash", () => {
|
||||||
expect(
|
expect(
|
||||||
whatsAppSubjectForPath("/servicos/criacao-de-sites/", contexts),
|
whatsAppSubjectForPath("/servicos/criacao-de-sites/", contexts),
|
||||||
).toBe("Sites profissionais");
|
).toBe("um site que traz pedidos pelo WhatsApp");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
+19
-3
@@ -1,3 +1,5 @@
|
|||||||
|
import { offerLabels } from "./offer-labels";
|
||||||
|
|
||||||
const COUNTRY = ["5", "5"];
|
const COUNTRY = ["5", "5"];
|
||||||
const AREA = ["4", "7"];
|
const AREA = ["4", "7"];
|
||||||
const NINE = ["9"];
|
const NINE = ["9"];
|
||||||
@@ -17,11 +19,14 @@ export function buildWhatsAppUrl(message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts:
|
* Assuntos dos serviços e nomes dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts:
|
||||||
* esse arquivo reúne o texto de todas as páginas e não deve ir inteiro para o navegador.
|
* esse arquivo reúne o texto de todas as páginas e não deve ir inteiro para o navegador.
|
||||||
*/
|
*/
|
||||||
export interface WhatsAppContexts {
|
export interface WhatsAppContexts {
|
||||||
/** slug do serviço -> nome do serviço */
|
/**
|
||||||
|
* slug do serviço -> `whatsappSubject` dele: uma frase em minúsculas que entra em "Quero conversar sobre {assunto}."
|
||||||
|
* (e não o nome do serviço, que daria "sobre Sites profissionais.").
|
||||||
|
*/
|
||||||
services: Record<string, string>;
|
services: Record<string, string>;
|
||||||
/** id do case -> nome do projeto ou do cliente */
|
/** id do case -> nome do projeto ou do cliente */
|
||||||
cases: Record<string, string>;
|
cases: Record<string, string>;
|
||||||
@@ -36,9 +41,20 @@ export const AI_REVIEW_SUBJECT = "a revisão de um aplicativo feito com IA";
|
|||||||
*/
|
*/
|
||||||
export const HOME_SUBJECT = "um site ou sistema para a minha empresa";
|
export const HOME_SUBJECT = "um site ou sistema para a minha empresa";
|
||||||
export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio";
|
export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio";
|
||||||
export const SERVICES_SUBJECT = "Sites, sistemas e aplicativos";
|
export const SERVICES_SUBJECT = "sites, sistemas e aplicativos";
|
||||||
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
|
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assunto de cada oferta da home, para o contato direto de WhatsApp do formulário quando o tipo de projeto já está
|
||||||
|
* escolhido ("Quero conversar sobre um site que traz pedidos pelo WhatsApp."). Os nomes das ofertas, em minúsculas e com artigo,
|
||||||
|
* que vêm de `offerLabels` (lib/offer-labels.ts), a mesma fonte do nome da oferta e da página dela.
|
||||||
|
*/
|
||||||
|
export const OFFER_SUBJECTS = {
|
||||||
|
site: offerLabels.site.whatsappSubject,
|
||||||
|
sistema: offerLabels.sistema.whatsappSubject,
|
||||||
|
revisao: offerLabels.revisao.whatsappSubject,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). "O case" concorda com qualquer título: sem ele, saía "o Lavação 047" e "o SEV Moda Íntima". */
|
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). "O case" concorda com qualquer título: sem ele, saía "o Lavação 047" e "o SEV Moda Íntima". */
|
||||||
export function similarProjectSubject(title: string) {
|
export function similarProjectSubject(title: string) {
|
||||||
return `um projeto parecido com o case ${title}`;
|
return `um projeto parecido com o case ${title}`;
|
||||||
|
|||||||
@@ -403,7 +403,8 @@ function checkStructuredData(scope, p) {
|
|||||||
(n) =>
|
(n) =>
|
||||||
n["@type"] === "Person" &&
|
n["@type"] === "Person" &&
|
||||||
Array.isArray(n.sameAs) &&
|
Array.isArray(n.sameAs) &&
|
||||||
n.sameAs.length >= 2,
|
// ao menos um perfil profissional no ar (o GitHub do autor foi suspenso; sobrou o LinkedIn)
|
||||||
|
n.sameAs.length >= 1,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return ld;
|
return ld;
|
||||||
|
|||||||
Reference in new issue
Block a user