feat(cases): mídia no topo, títulos de busca curtos e CreativeWork
Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
1 parent
c9a0b80656
commit
e80f73d075
52 files changed
+2593
-439
No files matched your search
@@ -1,15 +1,50 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { isValidElement } from "react";
|
||||
import { OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
import {
|
||||
OG_IMAGE_MAX_BYTES,
|
||||
OG_SIZE,
|
||||
OgCard,
|
||||
readProjectOgImage,
|
||||
} from "@/lib/og-card";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import Image from "./opengraph-image";
|
||||
import Image, { generateImageMetadata } from "./opengraph-image";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de Image (imagem de compartilhamento do case):
|
||||
* slug desconhecido, id da imagem diferente do case, case de cliente e projeto de laboratório.
|
||||
* slug desconhecido, id da imagem diferente do case, case com capa gravada (entregue como está, em JPEG,
|
||||
* abaixo de 150 KB), capa pesada demais (cartão simples) e projeto de laboratório.
|
||||
*/
|
||||
|
||||
/** O jsdom não tem `Response`; a rota roda no Node e devolve uma. Aqui basta guardar o corpo e os cabeçalhos. */
|
||||
class FakeResponse {
|
||||
constructor(
|
||||
readonly body: Uint8Array,
|
||||
readonly init: { headers: Record<string, string> },
|
||||
) {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.assign(globalThis, { Response: FakeResponse });
|
||||
});
|
||||
|
||||
jest.mock("@/lib/og-card", () => {
|
||||
const actual = jest.requireActual("@/lib/og-card");
|
||||
return { ...actual, readProjectOgImage: jest.fn(actual.readProjectOgImage) };
|
||||
});
|
||||
|
||||
const mockedReadProjectOgImage = jest.mocked(readProjectOgImage);
|
||||
const actualReadProjectOgImage =
|
||||
jest.requireActual<typeof import("@/lib/og-card")>(
|
||||
"@/lib/og-card",
|
||||
).readProjectOgImage;
|
||||
|
||||
afterEach(() => {
|
||||
mockedReadProjectOgImage.mockImplementation(actualReadProjectOgImage);
|
||||
});
|
||||
|
||||
jest.mock("next/navigation", () => ({
|
||||
notFound: jest.fn(() => {
|
||||
throw new Error("NEXT_NOT_FOUND");
|
||||
@@ -47,11 +82,32 @@ function sentCard() {
|
||||
}
|
||||
|
||||
describe("opengraph-image Image", () => {
|
||||
it("path: client case with the matching image id -> card labelled as a client case", async () => {
|
||||
it("path: case with a recorded cover -> the cover itself, in JPEG, under 150 KB, and no generated card", async () => {
|
||||
const caseStudy = getCase("sev-modaintima");
|
||||
await renderImage(caseStudy.id, caseStudy.id);
|
||||
const response = (await renderImage(
|
||||
caseStudy.id,
|
||||
caseStudy.id,
|
||||
)) as unknown as FakeResponse;
|
||||
|
||||
expect(notFound).not.toHaveBeenCalled();
|
||||
expect(mockedImageResponse).not.toHaveBeenCalled();
|
||||
expect(response).toBeInstanceOf(FakeResponse);
|
||||
expect(response.init.headers["Content-Type"]).toBe("image/jpeg");
|
||||
const cover = readFileSync(
|
||||
join(process.cwd(), "public/videos/sev-modaintima/og.jpg"),
|
||||
);
|
||||
expect(Buffer.from(response.body).equals(cover)).toBe(true);
|
||||
expect(response.body.length).toBeLessThan(150_000);
|
||||
});
|
||||
|
||||
it("path: cover that weighs 150 KB or more -> the plain generated card, with no background", async () => {
|
||||
const caseStudy = getCase("sev-modaintima");
|
||||
mockedReadProjectOgImage.mockReturnValue(undefined);
|
||||
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
|
||||
|
||||
const response = await renderImage(caseStudy.id, caseStudy.id);
|
||||
|
||||
expect(response).not.toBeInstanceOf(FakeResponse);
|
||||
expect(mockedImageResponse).toHaveBeenCalledTimes(1);
|
||||
const { card, options } = sentCard();
|
||||
expect(card.type).toBe(OgCard);
|
||||
@@ -60,10 +116,31 @@ describe("opengraph-image Image", () => {
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
});
|
||||
expect(card.props.backgroundImage).toMatch(/^data:image\/jpeg;base64,/);
|
||||
expect(card.props.backgroundImage).toBeUndefined();
|
||||
expect(options).toEqual(OG_SIZE);
|
||||
});
|
||||
|
||||
it("path: image metadata -> JPEG for covers, PNG for generated cards, and every case listed without a slug", () => {
|
||||
const [sev] = generateImageMetadata({ params: { slug: "sev-modaintima" } });
|
||||
const [twins] = generateImageMetadata({ params: { slug: "twins-lab" } });
|
||||
expect(sev).toMatchObject({
|
||||
id: "sev-modaintima",
|
||||
contentType: "image/jpeg",
|
||||
size: OG_SIZE,
|
||||
});
|
||||
expect(twins).toMatchObject({
|
||||
id: "twins-lab",
|
||||
contentType: "image/png",
|
||||
size: OG_SIZE,
|
||||
});
|
||||
|
||||
const all = generateImageMetadata({ params: {} });
|
||||
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
|
||||
expect(
|
||||
all.filter(({ contentType }) => contentType === "image/jpeg"),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("path: unknown slug -> notFound() and no image", async () => {
|
||||
await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow(
|
||||
"NEXT_NOT_FOUND",
|
||||
|
||||
@@ -2,9 +2,10 @@ import { notFound } from "next/navigation";
|
||||
import { ImageResponse } from "next/og";
|
||||
import {
|
||||
OG_CONTENT_TYPE,
|
||||
OG_PHOTO_CONTENT_TYPE,
|
||||
OG_SIZE,
|
||||
OgCard,
|
||||
readProjectOgBackground,
|
||||
readProjectOgImage,
|
||||
} from "@/lib/og-card";
|
||||
import { caseStudies, isClientCase } from "@/lib/site-content";
|
||||
|
||||
@@ -12,12 +13,15 @@ function getCaseStudy(slug: string) {
|
||||
return caseStudies.find(({ id }) => id === slug);
|
||||
}
|
||||
|
||||
// Quem tem a capa gravada (public/videos/<id>/og.jpg, abaixo de 150 KB) a entrega como está, em JPEG: o cartão em PNG com a capa de fundo passava de 270 KB.
|
||||
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
|
||||
return {
|
||||
id: caseStudy.id,
|
||||
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
|
||||
size: OG_SIZE,
|
||||
contentType: OG_CONTENT_TYPE,
|
||||
contentType: readProjectOgImage(caseStudy.id)
|
||||
? OG_PHOTO_CONTENT_TYPE
|
||||
: OG_CONTENT_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +50,16 @@ export default async function Image({
|
||||
const caseStudy = getCaseStudy(slug);
|
||||
if (!caseStudy || imageId !== caseStudy.id) notFound();
|
||||
|
||||
const photo = readProjectOgImage(caseStudy.id);
|
||||
if (photo) {
|
||||
return new Response(new Uint8Array(photo), {
|
||||
headers: {
|
||||
"Content-Type": OG_PHOTO_CONTENT_TYPE,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
<OgCard
|
||||
eyebrow={
|
||||
@@ -53,7 +67,6 @@ export default async function Image({
|
||||
}
|
||||
title={caseStudy.title}
|
||||
description={caseStudy.summary}
|
||||
backgroundImage={readProjectOgBackground(caseStudy.id)}
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import CasePage from "./page";
|
||||
|
||||
/**
|
||||
* Teste de caminhos-base de CasePage: os "fatos do projeto" no topo.
|
||||
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
|
||||
*/
|
||||
|
||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
describe("CasePage: fatos do projeto", () => {
|
||||
it("path: empty facts list -> no list in the hero, summary stays", async () => {
|
||||
const caseStudy = getCase("lavacao-047");
|
||||
caseStudy.facts = [];
|
||||
try {
|
||||
const ui = await CasePage({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
});
|
||||
render(ui);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("list", { name: "Fatos do projeto" }),
|
||||
).toBeNull();
|
||||
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||
} finally {
|
||||
delete caseStudy.facts;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -66,12 +66,20 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
|
||||
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByLabelText(`Vídeo do site ${caseStudy.title} no celular`),
|
||||
screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText(`Vídeo do site ${caseStudy.title} no computador`),
|
||||
screen.queryByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||
}),
|
||||
).toBeNull();
|
||||
// sem captura do computador, o topo fica em uma coluna, sem moldura de navegador
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(
|
||||
container.querySelector("section")?.querySelector(".grid"),
|
||||
).toBeNull();
|
||||
expect(container.querySelector(".ds-phone")).not.toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||
});
|
||||
@@ -115,11 +123,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
|
||||
await renderCase(caseStudy.id);
|
||||
|
||||
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
|
||||
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Conversar sobre um projeto parecido" }),
|
||||
screen.getByRole("link", {
|
||||
name: "Tem um problema parecido? Vamos conversar.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||
|
||||
+261
-58
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content";
|
||||
import CasesPage from "../page";
|
||||
import CasePage, { generateMetadata, generateStaticParams } from "./page";
|
||||
@@ -49,24 +49,34 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case. */
|
||||
function expectContactAndNoDeadLinks(caseStudy: { title: string }) {
|
||||
/**
|
||||
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case.
|
||||
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido".
|
||||
*/
|
||||
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||
const hrefs = screen
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href") ?? "");
|
||||
expect(hrefs).not.toContain("/#contact");
|
||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||
|
||||
const isClient = clientCaseIds.includes(caseStudy.id);
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
name: isClient
|
||||
? "Conversar sobre um projeto parecido"
|
||||
: "Tem um problema parecido? Vamos conversar.",
|
||||
});
|
||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||
expect(destination.origin).toBe("https://wa.me");
|
||||
expect(destination.searchParams.get("text")).toContain(
|
||||
`um projeto parecido com o da ${caseStudy.title}`,
|
||||
isClient
|
||||
? `um projeto parecido com o da ${caseStudy.title}`
|
||||
: `um problema parecido com o do projeto ${caseStudy.title}`,
|
||||
);
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||
// limão preenchido só no contato: o botão de play e os demais não o usam
|
||||
expect(cta.className).toContain("bg-[var(--slash)]");
|
||||
}
|
||||
|
||||
it("exposes the eight public cases, the two new client cases among them", async () => {
|
||||
@@ -80,16 +90,16 @@ it("exposes the eight public cases, the two new client cases among them", async
|
||||
for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) {
|
||||
const caseStudy = getCase(id);
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
const title = `${caseStudy.title}: ${caseStudy.subtitle}`;
|
||||
const { metaTitle: title, metaDescription: description } = caseStudy;
|
||||
const metadata = await generateMetadata({
|
||||
params: Promise.resolve({ slug: caseStudy.id }),
|
||||
});
|
||||
expect(metadata).toMatchObject({
|
||||
title,
|
||||
description: caseStudy.summary,
|
||||
description,
|
||||
alternates: { canonical: url },
|
||||
openGraph: { title, description: caseStudy.summary, url },
|
||||
twitter: { title, description: caseStudy.summary },
|
||||
openGraph: { title, description, url },
|
||||
twitter: { title, description },
|
||||
});
|
||||
expect(metadata).not.toHaveProperty("keywords");
|
||||
}
|
||||
@@ -101,7 +111,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveTextContent(caseStudy.headline);
|
||||
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
|
||||
expect(isBefore(heading, screen.getByText(caseStudy.summary))).toBe(true);
|
||||
expect(screen.getByText(/Case de cliente/)).toBeVisible();
|
||||
|
||||
const sectionNames = [
|
||||
@@ -130,33 +140,28 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
// sem dado real, não há seção de resultado
|
||||
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
|
||||
|
||||
// mídia: pôster e vídeo do computador e do celular
|
||||
const desktopVideo = screen.getByLabelText(
|
||||
`Vídeo do site ${caseStudy.title} no computador`,
|
||||
// mídia: o pôster do computador e do celular com um botão de play; nenhum vídeo parado com controles
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
const desktopPlay = screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||
});
|
||||
const mobilePlay = screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
||||
});
|
||||
const mediaSection = screen
|
||||
.getByRole("heading", { level: 2, name: "Como ficou" })
|
||||
.closest("section");
|
||||
expect(mediaSection?.querySelector(".ds-bframe")).toContainElement(
|
||||
desktopPlay,
|
||||
);
|
||||
const mobileVideo = screen.getByLabelText(
|
||||
`Vídeo do site ${caseStudy.title} no celular`,
|
||||
);
|
||||
expect(desktopVideo).toHaveAttribute(
|
||||
"poster",
|
||||
`/videos/${caseStudy.id}/desktop.webp`,
|
||||
);
|
||||
expect(mobileVideo).toHaveAttribute(
|
||||
"poster",
|
||||
`/videos/${caseStudy.id}/mobile.webp`,
|
||||
);
|
||||
expect(
|
||||
desktopVideo.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
|
||||
).toHaveLength(2);
|
||||
expect(container.querySelector(".ds-bframe")).toContainElement(desktopVideo);
|
||||
expect(container.querySelector(".ds-phone")).toContainElement(mobileVideo);
|
||||
expect(mediaSection?.querySelector(".ds-phone")).toContainElement(mobilePlay);
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
||||
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||
|
||||
expectContactAndNoDeadLinks(caseStudy);
|
||||
|
||||
// detalhes técnicos: recolhidos, no fim da página
|
||||
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
|
||||
const details = container.querySelector("details");
|
||||
if (!details) throw new Error("Missing technical details");
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
@@ -167,11 +172,16 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
for (const decision of caseStudy.decisions) {
|
||||
expect(within(details).getByText(decision)).not.toBeVisible();
|
||||
}
|
||||
const contactHeading = sectionHeadings[sectionHeadings.length - 1];
|
||||
expect(isBefore(contactHeading, details)).toBe(true);
|
||||
const doneSection = sectionHeadings[2].closest("section");
|
||||
expect(doneSection).toContainElement(details);
|
||||
expect(isBefore(screen.getByText(caseStudy.deliveries[0]), details)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isBefore(details, sectionHeadings[3])).toBe(true);
|
||||
expect(container.querySelectorAll("details")).toHaveLength(1);
|
||||
const lastSection = container.querySelector("article")?.lastElementChild;
|
||||
expect(lastSection).toContainElement(details);
|
||||
expect(
|
||||
container.querySelector("section[aria-label='Detalhes técnicos']"),
|
||||
).toBeNull();
|
||||
|
||||
// o que saiu: desafio verificável, limites da evidência, fontes e commits
|
||||
for (const name of [
|
||||
@@ -186,11 +196,14 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/);
|
||||
|
||||
const graph = readJsonLdEntities(container);
|
||||
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
|
||||
expect(graph.map((node) => node["@type"])).toEqual([
|
||||
"CreativeWork",
|
||||
"BreadcrumbList",
|
||||
]);
|
||||
expect(JSON.stringify(graph)).not.toMatch(
|
||||
/Article|FAQPage|ProfessionalService|LocalBusiness/,
|
||||
);
|
||||
const breadcrumb = graph[0] as {
|
||||
const breadcrumb = graph[1] as {
|
||||
itemListElement: Array<{
|
||||
"@type": string;
|
||||
position: number;
|
||||
@@ -216,7 +229,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
]);
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
@@ -253,7 +266,7 @@ it("renders the two new client cases from the portfolio data", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("labels a laboratory case, skips the media block and links its service", async () => {
|
||||
it("labels a laboratory case, skips the media block and shows its service as a card", async () => {
|
||||
const caseStudy = getCase("twins-lab");
|
||||
const { container } = await renderCase(caseStudy.id);
|
||||
|
||||
@@ -263,11 +276,12 @@ it("labels a laboratory case, skips the media block and links its service", asyn
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull();
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||
expect(screen.queryByText(/indispon/i)).toBeNull();
|
||||
|
||||
const relatedLinks = screen.getAllByRole("link", {
|
||||
name: /^Ver serviço relacionado:/,
|
||||
name: /^Ver o serviço:/,
|
||||
});
|
||||
expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||
getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`),
|
||||
@@ -276,6 +290,24 @@ it("labels a laboratory case, skips the media block and links its service", asyn
|
||||
expectContactAndNoDeadLinks(caseStudy);
|
||||
});
|
||||
|
||||
it("turns the contact of a laboratory case into the 'problema parecido' invitation", async () => {
|
||||
for (const id of labCaseIds) {
|
||||
const { unmount } = await renderCase(id);
|
||||
|
||||
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||
).toBeVisible();
|
||||
expectContactAndNoDeadLinks(getCase(id));
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the author's role and no site button for a client project without a public site", async () => {
|
||||
const caseStudy = getCase("as-servicos");
|
||||
expect(caseStudy.demoUrl).toBeNull();
|
||||
@@ -289,7 +321,9 @@ it("shows the author's role and no site button for a client project without a pu
|
||||
expect(screen.queryByText(/indispon/i)).toBeNull();
|
||||
// a capa continua nas molduras
|
||||
expect(
|
||||
screen.getByLabelText(`Vídeo do site ${caseStudy.title} no computador`),
|
||||
screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -327,26 +361,195 @@ it("shows the result section only when the case has real data", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("links every service related to a client case", async () => {
|
||||
const caseStudy = getCase("sc-plus");
|
||||
it("shows at most two related services, as cards under the contact button", async () => {
|
||||
// sev-modaintima tem quatro serviços ligados; só os dois primeiros viram cards
|
||||
const sev = getCase("sev-modaintima");
|
||||
const all = getRelatedServices(sev.id);
|
||||
expect(all.length).toBeGreaterThan(2);
|
||||
const { unmount } = await renderCase(sev.id);
|
||||
|
||||
const cards = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards.map((link) => link.getAttribute("href"))).toEqual(
|
||||
all.slice(0, 2).map(({ slug }) => `/servicos/${slug}`),
|
||||
);
|
||||
for (const link of cards) {
|
||||
const card = link.closest("article");
|
||||
expect(card).toHaveClass("ds-card");
|
||||
expect(
|
||||
within(card as HTMLElement).getByRole("heading", { level: 3 }),
|
||||
).toBeVisible();
|
||||
}
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||
).toBeNull();
|
||||
const contact = screen.getByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
});
|
||||
expect(isBefore(contact, cards[0])).toBe(true);
|
||||
unmount();
|
||||
|
||||
// sc-plus tem dois; as-servicos, um
|
||||
for (const [id, expected] of [
|
||||
["sc-plus", 2],
|
||||
["as-servicos", 1],
|
||||
] as const) {
|
||||
const { unmount: unmountCase } = await renderCase(id);
|
||||
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
||||
expect(links).toHaveLength(expected);
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
||||
getRelatedServices(id)
|
||||
.slice(0, 2)
|
||||
.map(({ slug }) => `/servicos/${slug}`),
|
||||
);
|
||||
expectContactAndNoDeadLinks(getCase(id));
|
||||
unmountCase();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the CreativeWork of a client case with creator, client and keywords", async () => {
|
||||
const caseStudy = getCase("lavacao-047");
|
||||
const { container } = await renderCase(caseStudy.id);
|
||||
|
||||
const relatedLinks = screen.getAllByRole("link", {
|
||||
name: /^Ver serviço relacionado:/,
|
||||
});
|
||||
expect(
|
||||
relatedLinks
|
||||
.map((link) => link.getAttribute("href"))
|
||||
.sort((a, b) => (a ?? "").localeCompare(b ?? "")),
|
||||
).toEqual(
|
||||
getRelatedServices(caseStudy.id)
|
||||
.map(({ slug }) => `/servicos/${slug}`)
|
||||
.sort(),
|
||||
const work = readJsonLdEntities(container).find(
|
||||
(node) => node["@type"] === "CreativeWork",
|
||||
);
|
||||
expectContactAndNoDeadLinks(caseStudy);
|
||||
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
|
||||
"BreadcrumbList",
|
||||
]);
|
||||
expect(work).toEqual({
|
||||
"@type": "CreativeWork",
|
||||
"@id": `${siteUrl}/cases/lavacao-047#creative-work`,
|
||||
name: "Lavação 047: Site e painel para lavação automotiva",
|
||||
description: caseStudy.metaDescription,
|
||||
url: `${siteUrl}/cases/lavacao-047`,
|
||||
image: `${siteUrl}/cases/lavacao-047/opengraph-image/lavacao-047`,
|
||||
inLanguage: "pt-BR",
|
||||
creator: { "@id": `${siteUrl}/#person` },
|
||||
sourceOrganization: {
|
||||
"@type": "Organization",
|
||||
name: "Lavação 047",
|
||||
url: "https://landing-page-lavacao047.vercel.app",
|
||||
},
|
||||
keywords: caseStudy.stack.join(", "),
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the CreativeWork of a laboratory case without a client", async () => {
|
||||
const caseStudy = getCase("twins-lab");
|
||||
const { container } = await renderCase(caseStudy.id);
|
||||
|
||||
const work = readJsonLdEntities(container).find(
|
||||
(node) => node["@type"] === "CreativeWork",
|
||||
);
|
||||
expect(work).toMatchObject({
|
||||
name: "Twins Lab: Painel ao vivo de um reator simulado",
|
||||
url: `${siteUrl}/cases/twins-lab`,
|
||||
creator: { "@id": `${siteUrl}/#person` },
|
||||
keywords: "Next.js, TypeScript, NextAuth, MongoDB, SSE",
|
||||
});
|
||||
expect(work).not.toHaveProperty("sourceOrganization");
|
||||
});
|
||||
|
||||
it("puts the project cover next to the title, and below it on a phone, in a browser frame", async () => {
|
||||
const caseStudy = getCase("sev-modaintima");
|
||||
const { container } = await renderCase(caseStudy.id);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
if (!hero) throw new Error("Missing hero");
|
||||
const frame = hero.querySelector(".ds-bframe");
|
||||
if (!frame) throw new Error("Missing hero frame");
|
||||
expect(frame.querySelector("img")).toHaveAttribute(
|
||||
"alt",
|
||||
`Captura de tela do site ${caseStudy.title}`,
|
||||
);
|
||||
expect(frame.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
// a capa abre a página: carrega antes das demais imagens
|
||||
expect(frame.querySelector("img")).not.toHaveAttribute("loading", "lazy");
|
||||
|
||||
// ordem do documento (a do celular): título, mídia, resumo
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
const summary = screen.getByText(caseStudy.summary);
|
||||
expect(hero).toContainElement(summary);
|
||||
expect(isBefore(heading, frame)).toBe(true);
|
||||
expect(isBefore(frame, summary)).toBe(true);
|
||||
// no computador: duas colunas, texto à esquerda e mídia à direita
|
||||
const grid = frame.closest(".grid");
|
||||
expect(grid?.className).toContain("lg:grid-cols-[");
|
||||
expect(frame.parentElement?.className).toContain("lg:col-start-2");
|
||||
expect(heading.parentElement?.className).toContain("lg:col-start-1");
|
||||
});
|
||||
|
||||
it("keeps the hero in one column when the case has no capture", async () => {
|
||||
const { container } = await renderCase("twins-lab");
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(hero?.querySelector(".grid")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the optional facts only when they are filled in", async () => {
|
||||
const caseStudy = getCase("lavacao-047");
|
||||
const empty = await renderCase(caseStudy.id);
|
||||
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
||||
empty.unmount();
|
||||
|
||||
caseStudy.facts = ["Fato de teste um", "Fato de teste dois"];
|
||||
try {
|
||||
await renderCase(caseStudy.id);
|
||||
const list = screen.getByRole("list", { name: "Fatos do projeto" });
|
||||
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||
expect(within(list).getByText("Fato de teste um")).toBeVisible();
|
||||
expect(isBefore(screen.getByText(caseStudy.summary), list)).toBe(true);
|
||||
} finally {
|
||||
delete caseStudy.facts;
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the video only when the visitor presses play, and moves focus to it", async () => {
|
||||
const caseStudy = getCase("sev-modaintima");
|
||||
const { container } = await renderCase(caseStudy.id);
|
||||
|
||||
const play = screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||
});
|
||||
expect(play).toBeVisible();
|
||||
expect(play.className).not.toContain("--slash");
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
|
||||
fireEvent.click(play);
|
||||
|
||||
const video = screen.getByLabelText(
|
||||
`Vídeo do site ${caseStudy.title} no computador`,
|
||||
);
|
||||
expect(video.tagName).toBe("VIDEO");
|
||||
expect(video).toHaveAttribute(
|
||||
"poster",
|
||||
`/videos/${caseStudy.id}/desktop.webp`,
|
||||
);
|
||||
expect(video).toHaveAttribute("controls");
|
||||
expect(video).toHaveFocus();
|
||||
expect(
|
||||
video.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
|
||||
).toHaveLength(2);
|
||||
expect(video.closest(".ds-bframe")).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
|
||||
}),
|
||||
).toBeNull();
|
||||
// o celular continua com o pôster até a pessoa tocar nele
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("gives the case page the same shell as the home, with no narrow max-width", async () => {
|
||||
const { container } = await renderCase("sev-modaintima");
|
||||
for (const shell of container.querySelectorAll(".ds-section-shell")) {
|
||||
expect(shell.className).not.toMatch(/max-w-/);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
|
||||
@@ -409,7 +612,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
|
||||
+52
-104
@@ -1,18 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import {
|
||||
pageTop,
|
||||
primaryButton,
|
||||
secondaryButtonDark,
|
||||
} from "@/app/components/button-styles";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
import {
|
||||
buildBreadcrumbList,
|
||||
buildCaseJsonLd,
|
||||
buildPageSocialMetadata,
|
||||
} from "@/lib/seo";
|
||||
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
|
||||
import { CaseContactSection } from "../_components/CaseContactSection";
|
||||
import { CaseHero } from "../_components/CaseHero";
|
||||
import { CaseMediaSection } from "../_components/CaseMediaSection";
|
||||
import { CaseRelatedServices } from "../_components/CaseRelatedServices";
|
||||
import { CaseResultSection } from "../_components/CaseResultSection";
|
||||
|
||||
interface CasePageProps {
|
||||
@@ -32,14 +29,13 @@ export async function generateMetadata({
|
||||
if (!caseStudy) return { title: "Case não encontrado" };
|
||||
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
const title = `${caseStudy.title}: ${caseStudy.subtitle}`;
|
||||
return {
|
||||
title,
|
||||
description: caseStudy.summary,
|
||||
title: caseStudy.metaTitle,
|
||||
description: caseStudy.metaDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url,
|
||||
title,
|
||||
description: caseStudy.summary,
|
||||
title: caseStudy.metaTitle,
|
||||
description: caseStudy.metaDescription,
|
||||
type: "article",
|
||||
}),
|
||||
};
|
||||
@@ -55,6 +51,7 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
buildCaseJsonLd(caseStudy),
|
||||
buildBreadcrumbList(pageUrl, [
|
||||
["Início", siteUrl],
|
||||
["Cases", `${siteUrl}/cases`],
|
||||
@@ -67,32 +64,12 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
<main>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<article>
|
||||
<section className={`ds-section ds-section-dark ${pageTop}`}>
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Início", href: "/" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
||||
]}
|
||||
/>
|
||||
<p className="ds-eyebrow">
|
||||
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
|
||||
{caseStudy.title}
|
||||
</p>
|
||||
<h1 className="ds-section-title text-balance text-white">
|
||||
{caseStudy.headline}
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
|
||||
{caseStudy.summary}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<CaseHero caseStudy={caseStudy} isClient={isClient} />
|
||||
|
||||
<section className="ds-section" aria-labelledby="case-context">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-grid-2 items-start">
|
||||
<div>
|
||||
<div className="max-w-[68ch]">
|
||||
<h2 id="case-context" className="ds-card-title !mt-0">
|
||||
{isClient
|
||||
? "O cliente e o contexto"
|
||||
@@ -108,7 +85,7 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className="max-w-[68ch]">
|
||||
<h2 id="case-problem" className="ds-card-title !mt-0">
|
||||
O problema
|
||||
</h2>
|
||||
@@ -119,15 +96,41 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
</section>
|
||||
|
||||
<section className="ds-section" aria-labelledby="case-done">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 id="case-done" className="ds-card-title !mt-0">
|
||||
O que foi feito
|
||||
</h2>
|
||||
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
|
||||
{caseStudy.deliveries.map((delivery) => (
|
||||
<li key={delivery}>{delivery}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="ds-section-shell">
|
||||
<div className="max-w-[860px]">
|
||||
<h2 id="case-done" className="ds-card-title !mt-0">
|
||||
O que foi feito
|
||||
</h2>
|
||||
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
|
||||
{caseStudy.deliveries.map((delivery) => (
|
||||
<li key={delivery}>{delivery}</li>
|
||||
))}
|
||||
</ul>
|
||||
<details className="mt-sp-8 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)]">
|
||||
Detalhes técnicos
|
||||
</summary>
|
||||
<div className="max-w-[80ch]">
|
||||
<p className="ds-kicker mt-sp-5">Tecnologias</p>
|
||||
<ul
|
||||
className="ds-tags !mt-sp-3 !pt-0"
|
||||
aria-label="Tecnologias do projeto"
|
||||
>
|
||||
{caseStudy.stack.map((item) => (
|
||||
<li key={item} className="ds-tag !whitespace-normal">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
|
||||
<ul className="ds-list !mt-sp-3 space-y-sp-2">
|
||||
{caseStudy.decisions.map((decision) => (
|
||||
<li key={decision}>{decision}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -135,62 +138,7 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
|
||||
<CaseResultSection result={caseStudy.result} />
|
||||
|
||||
<section
|
||||
className="ds-section ds-section-dark"
|
||||
aria-labelledby="case-contact"
|
||||
>
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
|
||||
Quer um projeto parecido com este?
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[60ch]">
|
||||
Conte o que você precisa. A conversa começa direto no WhatsApp,
|
||||
sem formulário.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<WhatsAppLink
|
||||
service={`um projeto parecido com o da ${caseStudy.title}`}
|
||||
placement="case_detail"
|
||||
className={primaryButton}
|
||||
>
|
||||
Conversar sobre um projeto parecido
|
||||
</WhatsAppLink>
|
||||
<Link href="/cases" className={secondaryButtonDark}>
|
||||
Ver outros cases
|
||||
</Link>
|
||||
</div>
|
||||
<CaseRelatedServices caseId={caseStudy.id} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section" aria-label="Detalhes técnicos">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<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)]">
|
||||
Detalhes técnicos
|
||||
</summary>
|
||||
<div className="max-w-[80ch]">
|
||||
<p className="ds-kicker mt-sp-5">Tecnologias</p>
|
||||
<ul
|
||||
className="ds-tags !mt-sp-3 !pt-0"
|
||||
aria-label="Tecnologias do projeto"
|
||||
>
|
||||
{caseStudy.stack.map((item) => (
|
||||
<li key={item} className="ds-tag !whitespace-normal">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
|
||||
<ul className="ds-list !mt-sp-3 space-y-sp-2">
|
||||
{caseStudy.decisions.map((decision) => (
|
||||
<li key={decision}>{decision}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getRelatedServices } from "@/lib/site-content";
|
||||
import { CaseContactSection } from "./CaseContactSection";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de
|
||||
* um projeto de laboratório ("problema parecido"), o link para a lista de cases e os serviços ligados.
|
||||
*/
|
||||
|
||||
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
|
||||
const nutri = { id: "nutriscan", title: "NutriScan" };
|
||||
|
||||
function messageOf(link: HTMLElement) {
|
||||
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
|
||||
}
|
||||
|
||||
describe("CaseContactSection", () => {
|
||||
it("path: client case -> 'projeto parecido' invitation, WhatsApp with the case title, services listed", () => {
|
||||
render(<CaseContactSection caseStudy={sev} isClient />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Quer um projeto parecido com este?",
|
||||
}),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
});
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre um projeto parecido com o da ${sev.title}.`,
|
||||
);
|
||||
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole("link", { name: /^Ver o serviço:/ }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||
).toHaveAttribute("href", "/cases");
|
||||
});
|
||||
|
||||
it("path: laboratory case -> 'problema parecido' invitation and no mention of a similar project", () => {
|
||||
render(<CaseContactSection caseStudy={nutri} isClient={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Tem um problema parecido? Vamos conversar.",
|
||||
});
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
}),
|
||||
).toBeNull();
|
||||
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua
|
||||
expect(getRelatedServices(nutri.id)).toHaveLength(0);
|
||||
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
primaryButton,
|
||||
secondaryButtonDark,
|
||||
} from "@/app/components/button-styles";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { CaseRelatedServices } from "./CaseRelatedServices";
|
||||
|
||||
/** Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido. */
|
||||
const contactCopy = {
|
||||
client: {
|
||||
title: "Quer um projeto parecido com este?",
|
||||
action: "Conversar sobre um projeto parecido",
|
||||
subject: (title: string) => `um projeto parecido com o da ${title}`,
|
||||
},
|
||||
lab: {
|
||||
title: "Fale sobre o seu caso",
|
||||
action: "Tem um problema parecido? Vamos conversar.",
|
||||
subject: (title: string) =>
|
||||
`um problema parecido com o do projeto ${title}`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */
|
||||
export function CaseContactSection({
|
||||
caseStudy,
|
||||
isClient,
|
||||
}: {
|
||||
caseStudy: Pick<CaseStudyContent, "id" | "title">;
|
||||
isClient: boolean;
|
||||
}) {
|
||||
const copy = contactCopy[isClient ? "client" : "lab"];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="ds-section ds-section-dark"
|
||||
aria-labelledby="case-contact"
|
||||
>
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[60ch]">
|
||||
Conte o que você precisa. A conversa começa direto no WhatsApp, sem
|
||||
formulário.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<WhatsAppLink
|
||||
service={copy.subject(caseStudy.title)}
|
||||
placement="case_detail"
|
||||
className={primaryButton}
|
||||
>
|
||||
{copy.action}
|
||||
</WhatsAppLink>
|
||||
<Link href="/cases" className={secondaryButtonDark}>
|
||||
Ver outros cases
|
||||
</Link>
|
||||
</div>
|
||||
<CaseRelatedServices caseId={caseStudy.id} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
displayUrl,
|
||||
} from "@/app/components/showcase/BrowserFrame";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
import "../../components/showcase/showcase.css";
|
||||
|
||||
/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */
|
||||
export function CaseCover({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { CaseCoverLink } from "./CaseCoverLink";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado,
|
||||
* com a capa dentro), os valores padrão de `sizes`, `priority` e `prefetch`, e os valores dados.
|
||||
*/
|
||||
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => (
|
||||
<a data-prefetch={String(prefetch)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
const props = {
|
||||
id: "sev-modaintima",
|
||||
title: "SEV Moda Íntima",
|
||||
demoUrl: "https://sevmodaintima.com.br",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("CaseCoverLink", () => {
|
||||
it("path: no desktop capture -> renders nothing, not even the link", () => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
const { container } = render(<CaseCoverLink {...props} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
|
||||
});
|
||||
|
||||
it("path: capture found -> a link to the case, out of reading order and tab order, around the cover", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
const { container } = render(<CaseCoverLink {...props} />);
|
||||
|
||||
const link = container.querySelector("a");
|
||||
expect(link).toHaveAttribute("href", `/cases/${props.id}`);
|
||||
expect(link).toHaveAttribute("aria-hidden", "true");
|
||||
expect(link).toHaveAttribute("tabindex", "-1");
|
||||
expect(link?.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
expect(
|
||||
screen.getByAltText(`Captura de tela do site ${props.title}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
const { container } = render(<CaseCoverLink {...props} />);
|
||||
|
||||
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||
expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
expect(container.querySelector("a")).toHaveAttribute(
|
||||
"data-prefetch",
|
||||
"undefined",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: sizes, priority and prefetch given -> all of them are used", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
const { container } = render(
|
||||
<CaseCoverLink {...props} sizes="320px" priority prefetch={false} />,
|
||||
);
|
||||
|
||||
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||
expect(image).toHaveAttribute("sizes", "320px");
|
||||
expect(image).not.toHaveAttribute("loading", "lazy");
|
||||
expect(container.querySelector("a")).toHaveAttribute(
|
||||
"data-prefetch",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: no demo address -> the browser bar falls back to the project title", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
const { container } = render(
|
||||
<CaseCoverLink id={props.id} title={props.title} demoUrl={null} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
props.title,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import { CaseCover } from "./CaseCover";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação,
|
||||
* porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada.
|
||||
*/
|
||||
export function CaseCoverLink({
|
||||
id,
|
||||
title,
|
||||
demoUrl,
|
||||
sizes = "(min-width: 1024px) 560px, 100vw",
|
||||
priority = false,
|
||||
prefetch,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
sizes?: string;
|
||||
priority?: boolean;
|
||||
prefetch?: boolean;
|
||||
}) {
|
||||
if (!getCaseMedia(id, "desktop")) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/cases/${id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
prefetch={prefetch}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={id}
|
||||
title={title}
|
||||
demoUrl={demoUrl}
|
||||
sizes={sizes}
|
||||
priority={priority}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import { CaseHero } from "./CaseHero";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
|
||||
* laboratório, e a lista de fatos ausente, vazia ou preenchida. A existência da captura é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
const client = getCase("sev-modaintima");
|
||||
const lab = getCase("twins-lab");
|
||||
|
||||
function withCapture(has: boolean) {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
has ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("CaseHero: captura gravada e tipo de case", () => {
|
||||
it("path: client case with a capture -> two columns, the cover on the right, the client eyebrow", () => {
|
||||
withCapture(true);
|
||||
const { container } = render(<CaseHero caseStudy={client} isClient />);
|
||||
|
||||
expect(container.querySelector(".ds-section-shell")).toHaveClass("grid");
|
||||
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Case de cliente/)).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: client.headline }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: client case without a capture -> one column and no cover", () => {
|
||||
withCapture(false);
|
||||
const { container } = render(<CaseHero caseStudy={client} isClient />);
|
||||
|
||||
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
|
||||
"grid",
|
||||
);
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(screen.getByText(client.summary)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: laboratory case with a capture -> the cover shows, with the laboratory eyebrow", () => {
|
||||
withCapture(true);
|
||||
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
|
||||
|
||||
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||
expect(screen.queryByText(/Case de cliente/)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: laboratory case without a capture -> one column, laboratory eyebrow, title and summary", () => {
|
||||
withCapture(false);
|
||||
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
|
||||
|
||||
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
|
||||
"grid",
|
||||
);
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||
expect(screen.getByText(lab.summary)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
|
||||
withCapture(false);
|
||||
render(<CaseHero caseStudy={client} isClient />);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/",
|
||||
"/cases",
|
||||
`/cases/${client.id}`,
|
||||
]);
|
||||
expect(links[2]).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CaseHero: fatos do projeto", () => {
|
||||
const withFacts = (facts: string[] | undefined) => ({
|
||||
...client,
|
||||
facts,
|
||||
});
|
||||
|
||||
it("path: no facts field -> no list", () => {
|
||||
withCapture(false);
|
||||
render(<CaseHero caseStudy={withFacts(undefined)} isClient />);
|
||||
|
||||
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: empty facts list -> no list", () => {
|
||||
withCapture(false);
|
||||
render(<CaseHero caseStudy={withFacts([])} isClient />);
|
||||
|
||||
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: filled facts -> one item each, after the summary", () => {
|
||||
withCapture(false);
|
||||
render(
|
||||
<CaseHero
|
||||
caseStudy={withFacts(["No ar desde 2025", "Dois painéis"])}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
const list = screen.getByRole("list", { name: "Fatos do projeto" });
|
||||
expect(list.querySelectorAll("li")).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByText(client.summary).compareDocumentPosition(list) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import { pageTop } from "@/app/components/button-styles";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { CaseCover } from "./CaseCover";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
|
||||
function CaseFacts({ facts }: { facts?: string[] }) {
|
||||
if (!facts?.length) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className="mt-sp-5 flex flex-wrap gap-x-sp-6 gap-y-sp-2 text-[15px] leading-6 text-white/80"
|
||||
aria-label="Fatos do projeto"
|
||||
>
|
||||
{facts.map((fact) => (
|
||||
<li key={fact}>{fact}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pôster do site na coluna da direita (no celular, logo abaixo do título). */
|
||||
function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
|
||||
return (
|
||||
<div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita;
|
||||
* no celular a mídia vem logo abaixo do título.
|
||||
*/
|
||||
export function CaseHero({
|
||||
caseStudy,
|
||||
isClient,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
isClient: boolean;
|
||||
}) {
|
||||
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
|
||||
|
||||
return (
|
||||
<section className={`ds-section ds-section-dark ${pageTop}`}>
|
||||
<div
|
||||
className={`ds-section-shell ${
|
||||
hasMedia
|
||||
? "grid items-center gap-y-sp-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16 lg:gap-y-0"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-[860px] lg:col-start-1 lg:row-start-1 lg:self-end">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Início", href: "/" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
||||
]}
|
||||
/>
|
||||
<p className="ds-eyebrow">
|
||||
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
|
||||
{caseStudy.title}
|
||||
</p>
|
||||
<h1 className="ds-section-title text-balance text-white">
|
||||
{caseStudy.headline}
|
||||
</h1>
|
||||
</div>
|
||||
{hasMedia ? <CaseHeroCover caseStudy={caseStudy} /> : null}
|
||||
<div className="max-w-[68ch] lg:col-start-1 lg:row-start-2 lg:self-start">
|
||||
<p className="ds-copy !text-[rgba(255,255,255,.78)]">
|
||||
{caseStudy.summary}
|
||||
</p>
|
||||
<CaseFacts facts={caseStudy.facts} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { CaseMedia } from "./CaseMedia";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum.
|
||||
* Cada quadro mostra o pôster com um botão de play; o vídeo só aparece depois do clique.
|
||||
* A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/.
|
||||
*/
|
||||
|
||||
@@ -23,8 +24,10 @@ const props = {
|
||||
title: "SEV Moda Íntima",
|
||||
demoUrl: "https://sevmodaintima.com.br",
|
||||
};
|
||||
const desktopLabel = `Vídeo do site ${props.title} no computador`;
|
||||
const mobileLabel = `Vídeo do site ${props.title} no celular`;
|
||||
const desktopPlay = `Reproduzir o vídeo do site ${props.title} no computador`;
|
||||
const mobilePlay = `Reproduzir o vídeo do site ${props.title} no celular`;
|
||||
const desktopVideo = `Vídeo do site ${props.title} no computador`;
|
||||
const mobileVideo = `Vídeo do site ${props.title} no celular`;
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
@@ -36,10 +39,10 @@ describe("CaseMedia", () => {
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(container.querySelector(".ds-bframe")).toContainElement(
|
||||
screen.getByLabelText(desktopLabel),
|
||||
screen.getByRole("button", { name: desktopPlay }),
|
||||
);
|
||||
expect(container.querySelector(".ds-phone")).toContainElement(
|
||||
screen.getByLabelText(mobileLabel),
|
||||
screen.getByRole("button", { name: mobilePlay }),
|
||||
);
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
@@ -63,8 +66,10 @@ describe("CaseMedia", () => {
|
||||
withCaptures(["mobile"]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(screen.getByLabelText(mobileLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(desktopLabel)).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: mobilePlay }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: desktopPlay })).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(container.querySelector(".ds-phone")).not.toBeNull();
|
||||
expect(screen.queryByText(/no computador\./)).toBeNull();
|
||||
@@ -74,13 +79,47 @@ describe("CaseMedia", () => {
|
||||
withCaptures(["desktop"]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(screen.getByLabelText(desktopLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(mobileLabel)).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: desktopPlay }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: mobilePlay })).toBeNull();
|
||||
expect(container.querySelector(".ds-phone")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).not.toBeNull();
|
||||
expect(screen.queryByText("No celular.")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: both captures -> no video until play, then only the pressed frame plays", () => {
|
||||
withCaptures(["desktop", "mobile"]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
// o pôster do computador vem com texto alternativo vazio: o botão já descreve o quadro
|
||||
expect(container.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||
"alt",
|
||||
"",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: mobilePlay }));
|
||||
|
||||
const phone = screen.getByLabelText(mobileVideo);
|
||||
expect(container.querySelector(".ds-phone")).toContainElement(phone);
|
||||
expect(phone).toHaveAttribute("poster", `/videos/${props.id}/mobile.webp`);
|
||||
expect(phone.querySelectorAll("source")).toHaveLength(2);
|
||||
expect(screen.queryByLabelText(desktopVideo)).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: desktopPlay }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("video")).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: desktopPlay }));
|
||||
expect(screen.getByLabelText(desktopVideo)).toHaveAttribute(
|
||||
"poster",
|
||||
`/videos/${props.id}/desktop.webp`,
|
||||
);
|
||||
expect(container.querySelectorAll("video")).toHaveLength(2);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no demo address -> the browser bar falls back to the project title", () => {
|
||||
withCaptures(["desktop"]);
|
||||
const { container } = render(
|
||||
|
||||
@@ -3,43 +3,13 @@ import {
|
||||
displayUrl,
|
||||
} from "@/app/components/showcase/BrowserFrame";
|
||||
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
|
||||
import type { ShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { CasePlayer } from "./CasePlayer";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
import "../../components/showcase/showcase.css";
|
||||
|
||||
function CaseVideo({
|
||||
assets,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
assets: ShowcaseAssets;
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<video
|
||||
className={className}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
aria-label={label}
|
||||
disablePictureInPicture
|
||||
>
|
||||
{assets.sources.map((source) => (
|
||||
<source key={source.src} src={source.src} type={source.type} />
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular).
|
||||
* O vídeo só baixa quando a pessoa aperta o play. Sem captura gravada, não mostra nada.
|
||||
* Cada quadro mostra o pôster com um botão de play; o vídeo só baixa quando a pessoa aperta o botão.
|
||||
* Sem captura gravada, não mostra nada.
|
||||
*/
|
||||
export function CaseMedia({
|
||||
id,
|
||||
@@ -55,16 +25,12 @@ export function CaseMedia({
|
||||
if (!desktop && !mobile) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-sp-8 grid items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="mt-sp-8 grid max-w-[1040px] items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
{desktop ? (
|
||||
<figure className="min-w-0">
|
||||
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||
<CaseVideo
|
||||
assets={desktop}
|
||||
label={`Vídeo do site ${title} no computador`}
|
||||
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||
/>
|
||||
<CasePlayer assets={desktop} title={title} device="desktop" />
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
|
||||
@@ -75,11 +41,7 @@ export function CaseMedia({
|
||||
{mobile ? (
|
||||
<figure className="mx-auto w-[min(62vw,220px)] md:mx-0">
|
||||
<PhoneFrame poster={mobile.poster}>
|
||||
<CaseVideo
|
||||
assets={mobile}
|
||||
label={`Vídeo do site ${title} no celular`}
|
||||
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||
/>
|
||||
<CasePlayer assets={mobile} title={title} device="mobile" />
|
||||
</PhoneFrame>
|
||||
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
|
||||
No celular.
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CaseMediaSection({
|
||||
className="ds-section ds-section-dark"
|
||||
aria-labelledby="case-media"
|
||||
>
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
|
||||
Como ficou
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { CasePlayer } from "./CasePlayer";
|
||||
|
||||
const title = "SEV Moda Íntima";
|
||||
|
||||
describe("CasePlayer", () => {
|
||||
it("shows the poster and an accessible play button instead of a paused video", () => {
|
||||
const { container } = render(
|
||||
<div className="relative">
|
||||
<CasePlayer
|
||||
assets={getShowcaseAssets("sev-modaintima", "desktop")}
|
||||
title={title}
|
||||
device="desktop"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
// a imagem é decorativa: o botão já diz o que o quadro mostra
|
||||
const poster = container.querySelector("img");
|
||||
expect(poster).toHaveAttribute("alt", "");
|
||||
expect(poster?.getAttribute("src")).toContain(
|
||||
encodeURIComponent("/videos/sev-modaintima/desktop.webp"),
|
||||
);
|
||||
const button = screen.getByRole("button", {
|
||||
name: `Reproduzir o vídeo do site ${title} no computador`,
|
||||
});
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
// só botão de contato é limão: o de play é escuro, com contorno branco
|
||||
expect(button.innerHTML).not.toContain("--slash");
|
||||
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
|
||||
const assets = getShowcaseAssets("lavacao-047", "desktop");
|
||||
render(
|
||||
<div className="relative">
|
||||
<CasePlayer assets={assets} title="Lavação 047" device="desktop" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
const video = screen.getByLabelText(
|
||||
"Vídeo do site Lavação 047 no computador",
|
||||
);
|
||||
expect(video).toHaveFocus();
|
||||
expect(video).toHaveAttribute("controls");
|
||||
expect(video).toHaveAttribute("poster", assets.poster);
|
||||
expect(video).toHaveAttribute("width", String(assets.width));
|
||||
expect(video).toHaveAttribute("height", String(assets.height));
|
||||
expect((video as HTMLVideoElement).muted).toBe(true);
|
||||
expect((video as HTMLVideoElement).loop).toBe(true);
|
||||
expect(
|
||||
[...video.querySelectorAll("source")].map((source) => [
|
||||
source.getAttribute("src"),
|
||||
source.getAttribute("type"),
|
||||
]),
|
||||
).toEqual(assets.sources.map(({ src, type }) => [src, type]));
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the poster to the phone frame on mobile and names the device in the labels", () => {
|
||||
const { container } = render(
|
||||
<div className="relative">
|
||||
<CasePlayer
|
||||
assets={getShowcaseAssets("sc-plus", "mobile")}
|
||||
title="SC Plus Aquicultura"
|
||||
device="mobile"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Reproduzir o vídeo do site SC Plus Aquicultura no celular",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByLabelText("Vídeo do site SC Plus Aquicultura no celular"),
|
||||
).toHaveAttribute("poster", "/videos/sc-plus/mobile.webp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Play } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Device, ShowcaseAssets } from "@/lib/project-showcase";
|
||||
|
||||
const deviceLabel: Record<Device, string> = {
|
||||
desktop: "no computador",
|
||||
mobile: "no celular",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão:
|
||||
* antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador.
|
||||
* Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho.
|
||||
*/
|
||||
export function CasePlayer({
|
||||
assets,
|
||||
title,
|
||||
device,
|
||||
sizes = "(min-width: 1280px) 900px, 100vw",
|
||||
}: {
|
||||
assets: ShowcaseAssets;
|
||||
title: string;
|
||||
device: Device;
|
||||
sizes?: string;
|
||||
}) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// o botão sai de cena ao tocar: o foco vai para o vídeo, para quem usa o teclado não o perder
|
||||
useEffect(() => {
|
||||
if (playing) videoRef.current?.focus();
|
||||
}, [playing]);
|
||||
|
||||
if (playing) {
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||
autoPlay
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
tabIndex={0}
|
||||
aria-label={`Vídeo do site ${title} ${deviceLabel[device]}`}
|
||||
disablePictureInPicture
|
||||
>
|
||||
{assets.sources.map((source) => (
|
||||
<source key={source.src} src={source.src} type={source.type} />
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* o quadro do celular já mostra o pôster como fundo; no computador a imagem entra aqui */}
|
||||
{device === "desktop" ? (
|
||||
<Image
|
||||
src={assets.poster}
|
||||
alt=""
|
||||
fill
|
||||
sizes={sizes}
|
||||
className="object-cover object-top"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying(true)}
|
||||
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
|
||||
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
|
||||
>
|
||||
<Play className="ml-1 size-6" fill="currentColor" />
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import { textLink } from "@/app/components/button-styles";
|
||||
import { textLinkDark } from "@/app/components/button-styles";
|
||||
import { getRelatedServices } from "@/lib/site-content";
|
||||
|
||||
/** Links para os serviços que citam este case. Sem serviço relacionado, não mostra nada. */
|
||||
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
|
||||
export const MAX_RELATED_SERVICES = 2;
|
||||
|
||||
/** Cards dos serviços que citam este case (no máximo dois), sobre fundo escuro. Sem serviço relacionado, não mostra nada. */
|
||||
export function CaseRelatedServices({ caseId }: { caseId: string }) {
|
||||
const services = getRelatedServices(caseId);
|
||||
const services = getRelatedServices(caseId).slice(0, MAX_RELATED_SERVICES);
|
||||
if (services.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-sp-6 flex flex-wrap gap-x-sp-6">
|
||||
{services.map((service) => (
|
||||
<Link
|
||||
key={service.slug}
|
||||
className={textLink}
|
||||
href={`/servicos/${service.slug}`}
|
||||
>
|
||||
Ver serviço relacionado: {service.navLabel}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-sp-10">
|
||||
<p className="ds-kicker">Serviços ligados a este projeto</p>
|
||||
<ul className="mt-sp-4 grid gap-sp-4 md:grid-cols-2">
|
||||
{services.map((service) => (
|
||||
<li key={service.slug} className="flex">
|
||||
<article className="ds-card w-full">
|
||||
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
|
||||
<p className="ds-copy">{service.intro}</p>
|
||||
<Link
|
||||
className={`${textLinkDark} mt-sp-4 self-start`}
|
||||
href={`/servicos/${service.slug}`}
|
||||
>
|
||||
Ver o serviço: {service.navLabel}
|
||||
</Link>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,11 @@ export function CaseResultSection({ result }: { result?: string[] }) {
|
||||
|
||||
return (
|
||||
<section className="ds-section" aria-labelledby="case-result">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="case-result" className="ds-card-title !mt-0">
|
||||
Resultado
|
||||
</h2>
|
||||
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
|
||||
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
|
||||
{result.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "server-only";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { clientCases } from "@/lib/site-content";
|
||||
import CasesPage from "./page";
|
||||
|
||||
/**
|
||||
* Carregamento da lista de cases: a primeira capa (a maior imagem acima da dobra) tem prioridade,
|
||||
* e os links das capas não pré-carregam as páginas dos cases sozinhos.
|
||||
*/
|
||||
|
||||
type LinkProps = ComponentProps<typeof import("next/link").default>;
|
||||
|
||||
/** Propriedades de cada `Link` renderizado: o `prefetch` não aparece no HTML, então a rota é espiada aqui. */
|
||||
const mockLinkProps: LinkProps[] = [];
|
||||
|
||||
jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href, prefetch, ...anchor }: LinkProps) => {
|
||||
mockLinkProps.push({ children, href, prefetch, ...anchor });
|
||||
return (
|
||||
<a href={String(href)} {...(anchor as Record<string, unknown>)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockLinkProps.length = 0;
|
||||
});
|
||||
|
||||
function coverImages(container: HTMLElement) {
|
||||
return clientCases.map((caseStudy) => {
|
||||
const image = container
|
||||
.querySelector(`a[href="/cases/${caseStudy.id}"][aria-hidden="true"]`)
|
||||
?.querySelector("img");
|
||||
if (!image) throw new Error(`Missing cover of ${caseStudy.id}`);
|
||||
return image;
|
||||
});
|
||||
}
|
||||
|
||||
describe("CasesPage: carregamento das capas", () => {
|
||||
it("loads the first cover with priority and the others lazily", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const [first, ...others] = coverImages(container);
|
||||
expect(first).not.toHaveAttribute("loading", "lazy");
|
||||
expect(others.length).toBeGreaterThan(0);
|
||||
for (const image of others) {
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not prefetch the case pages from the cover links, nor from the card buttons", () => {
|
||||
render(<CasesPage />);
|
||||
|
||||
const toCases = mockLinkProps.filter(({ href }) =>
|
||||
String(href).startsWith("/cases/"),
|
||||
);
|
||||
// uma capa e um botão por case de cliente, e um botão por projeto do laboratório
|
||||
expect(toCases.length).toBeGreaterThanOrEqual(clientCases.length * 2);
|
||||
for (const props of toCases) {
|
||||
expect(props.prefetch).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
+21
-20
@@ -17,13 +17,12 @@ import {
|
||||
labCases,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import { CaseCover } from "./_components/CaseCover";
|
||||
import { getCaseMedia } from "./_components/case-media";
|
||||
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
||||
|
||||
const pageUrl = `${siteUrl}/cases`;
|
||||
const pageTitle = "Cases: Sites e Sistemas Feitos para Empresas";
|
||||
const pageTitle = "Cases: sites e sistemas para empresas";
|
||||
const pageDescription =
|
||||
"Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono, páginas institucionais e mais. Confira cada projeto e converse sobre o seu.";
|
||||
"Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
|
||||
const allCases = [...clientCases, ...labCases];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -41,33 +40,34 @@ function CaseCard({
|
||||
caseStudy,
|
||||
withCover,
|
||||
ctaLabel,
|
||||
priority = false,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
withCover: boolean;
|
||||
ctaLabel: string;
|
||||
/** Capa que abre a página (a primeira, o maior elemento visível): carrega antes das demais. */
|
||||
priority?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article className="ds-card !h-auto">
|
||||
{withCover && getCaseMedia(caseStudy.id, "desktop") ? (
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
{withCover ? (
|
||||
<CaseCoverLink
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
priority={priority}
|
||||
prefetch={false}
|
||||
/>
|
||||
) : null}
|
||||
<div className="ds-kicker">{caseStudy.title}</div>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<Link href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
prefetch={false}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
@@ -142,12 +142,13 @@ export default function CasesPage() {
|
||||
Projetos de clientes
|
||||
</h2>
|
||||
<div className="ds-grid-2 mt-sp-8 items-start">
|
||||
{clientCases.map((caseStudy) => (
|
||||
{clientCases.map((caseStudy, index) => (
|
||||
<CaseCard
|
||||
key={caseStudy.id}
|
||||
caseStudy={caseStudy}
|
||||
withCover
|
||||
ctaLabel="Ver o case"
|
||||
priority={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de Breadcrumb: o primeiro item não tem separador,
|
||||
* os seguintes têm, e só o último é a página atual (inclusive quando é o único).
|
||||
*/
|
||||
|
||||
describe("Breadcrumb", () => {
|
||||
it("path: several items -> a separator before every item but the first", () => {
|
||||
const { container } = render(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Início", href: "/" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const entries = Array.from(container.querySelectorAll("li"));
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries[0].querySelector("span[aria-hidden='true']")).toBeNull();
|
||||
expect(
|
||||
entries[1].querySelector("span[aria-hidden='true']"),
|
||||
).toHaveTextContent("/");
|
||||
expect(
|
||||
entries[2].querySelector("span[aria-hidden='true']"),
|
||||
).toHaveTextContent("/");
|
||||
expect(entries[0].className).toBe("");
|
||||
expect(entries[1].className).toContain("flex");
|
||||
});
|
||||
|
||||
it("path: a single item -> no separator, and it is the current page", () => {
|
||||
const { container } = render(
|
||||
<Breadcrumb items={[{ label: "Início", href: "/" }]} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("span[aria-hidden='true']")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Início" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: no items -> an empty list inside the navigation", () => {
|
||||
render(<Breadcrumb items={[]} />);
|
||||
|
||||
const nav = screen.getByRole("navigation");
|
||||
expect(nav.querySelectorAll("li")).toHaveLength(0);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
|
||||
describe("Breadcrumb", () => {
|
||||
const items = [
|
||||
{ label: "Início", href: "/" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
|
||||
];
|
||||
|
||||
it("marks only the last item as the current page", () => {
|
||||
render(<Breadcrumb items={items} />);
|
||||
|
||||
const links = within(
|
||||
screen.getByRole("navigation", { name: "Trilha de navegação" }),
|
||||
).getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("aria-current"))).toEqual([
|
||||
null,
|
||||
null,
|
||||
"page",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the text at 15 px or more, as the body text of the inner pages", () => {
|
||||
const { container } = render(<Breadcrumb items={items} />);
|
||||
|
||||
const list = container.querySelector("ol");
|
||||
expect(list).toHaveClass("text-[15px]");
|
||||
expect(list?.className).not.toMatch(/text-(xs|sm)\b|text-\[1[0-4]px\]/);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,8 @@ export function Breadcrumb({
|
||||
items: readonly { label: string; href: string }[];
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
|
||||
<nav aria-label="Trilha de navegação" className="mb-sp-6">
|
||||
<ol className="flex flex-wrap items-center gap-sp-2 text-[15px]">
|
||||
{items.map(({ label, href }, index) => (
|
||||
<li
|
||||
key={href}
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { trackCvDownload } from "@/lib/analytics";
|
||||
import CurriculoPage from "./page";
|
||||
import CurriculoPage, { metadata } from "./page";
|
||||
|
||||
jest.mock("@/lib/analytics", () => ({ trackCvDownload: jest.fn() }));
|
||||
|
||||
it("uses the same shell as the home, with no narrow max-width, and a template-friendly title", () => {
|
||||
const { container } = render(CurriculoPage());
|
||||
|
||||
const shells = container.querySelectorAll(".ds-section-shell");
|
||||
expect(shells.length).toBeGreaterThan(0);
|
||||
for (const shell of shells) {
|
||||
expect(shell.className).not.toMatch(/max-w-/);
|
||||
}
|
||||
// o sufixo " | Evandro.dev.br" vem do layout: o título não o repete
|
||||
expect(metadata.title).toBe("Currículo de Evandro Fonseca Junior");
|
||||
expect(metadata.alternates?.canonical).toBe(
|
||||
"https://evandro.dev.br/curriculo",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the Innovo company name with the right accents", () => {
|
||||
render(CurriculoPage());
|
||||
|
||||
expect(
|
||||
screen.getByText(/Innovo Tecnologia da Informação Ltda\./),
|
||||
).toBeVisible();
|
||||
expect(document.body.textContent).not.toMatch(/Informacao/);
|
||||
});
|
||||
|
||||
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
|
||||
render(CurriculoPage());
|
||||
|
||||
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||
const breadcrumb = screen.getByRole("navigation", {
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||
"Início",
|
||||
|
||||
@@ -9,20 +9,21 @@ import {
|
||||
buildPageSocialMetadata,
|
||||
buildProfilePageJsonLd,
|
||||
} from "@/lib/seo";
|
||||
import { siteName, siteUrl } from "@/lib/site-content";
|
||||
import { siteUrl } from "@/lib/site-content";
|
||||
import CvDownloadLink from "./CvDownloadLink";
|
||||
|
||||
const pageUrl = `${siteUrl}/curriculo`;
|
||||
const pageTitle = "Currículo de Evandro Fonseca Junior";
|
||||
const pageDescription =
|
||||
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software: experiência, formação e competências técnicas.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` },
|
||||
description:
|
||||
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url: pageUrl,
|
||||
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
|
||||
description:
|
||||
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
type: "profile",
|
||||
}),
|
||||
};
|
||||
@@ -43,7 +44,7 @@ export default function CurriculoPage() {
|
||||
<main>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<section className={`ds-section ds-section-dark ${pageTop}`}>
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<div className="ds-section-shell">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Início", href: "/" },
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Metadata } from "next";
|
||||
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
|
||||
import { generateMetadata as generateCaseMetadata } from "./cases/[slug]/page";
|
||||
import { metadata as casesMetadata } from "./cases/page";
|
||||
import { metadata as curriculoMetadata } from "./curriculo/page";
|
||||
import { metadata as layoutMetadata } from "./layout";
|
||||
import { metadata as notFoundMetadata } from "./not-found";
|
||||
import { metadata as privacyMetadata } from "./privacidade/page";
|
||||
import { generateMetadata as generateServiceMetadata } from "./servicos/[slug]/page";
|
||||
import { metadata as servicesMetadata } from "./servicos/page";
|
||||
|
||||
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
|
||||
jest.mock("next/font/google", () => ({
|
||||
Geist_Mono: () => ({ variable: "font-mono-var" }),
|
||||
Space_Grotesk: () => ({ variable: "font-display-var" }),
|
||||
}));
|
||||
jest.mock("./globals.css", () => ({}));
|
||||
|
||||
/** Sufixo que o layout acrescenta a todo título (" | Evandro.dev.br"): 17 caracteres, não 16; por isso o teto efetivo do título é 43. */
|
||||
const TITLE_SUFFIX = " | Evandro.dev.br";
|
||||
const MAX_META_TITLE = 44;
|
||||
const MAX_FULL_TITLE = 60;
|
||||
const MAX_DESCRIPTION = 155;
|
||||
|
||||
interface Route {
|
||||
route: string;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
async function collectRoutes(): Promise<Route[]> {
|
||||
const cases = await Promise.all(
|
||||
caseStudies.map(async ({ id }) => ({
|
||||
route: `/cases/${id}`,
|
||||
metadata: await generateCaseMetadata({
|
||||
params: Promise.resolve({ slug: id }),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const services = await Promise.all(
|
||||
servicePages.map(async ({ slug }) => ({
|
||||
route: `/servicos/${slug}`,
|
||||
metadata: await generateServiceMetadata({
|
||||
params: Promise.resolve({ slug }),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
return [
|
||||
{ route: "/cases", metadata: casesMetadata },
|
||||
...cases,
|
||||
{ route: "/servicos", metadata: servicesMetadata },
|
||||
...services,
|
||||
{ route: "/curriculo", metadata: curriculoMetadata },
|
||||
{ route: "/privacidade", metadata: privacyMetadata },
|
||||
{ route: "/not-found", metadata: notFoundMetadata },
|
||||
];
|
||||
}
|
||||
|
||||
function textOf(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
throw new Error(`Esperava um texto, recebi ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
describe("títulos e descrições das rotas de cases, serviços, currículo, privacidade e 404", () => {
|
||||
it("cobre as 19 rotas do escopo", async () => {
|
||||
expect(await collectRoutes()).toHaveLength(1 + 8 + 1 + 6 + 1 + 1 + 1);
|
||||
expect(TITLE_SUFFIX).toHaveLength(17);
|
||||
});
|
||||
|
||||
it(`mantém o título em até ${MAX_META_TITLE} caracteres, ${MAX_FULL_TITLE} com o sufixo do layout`, async () => {
|
||||
const violations = (await collectRoutes()).flatMap(
|
||||
({ route, metadata }) => {
|
||||
const title = textOf(metadata.title);
|
||||
const full = `${title}${TITLE_SUFFIX}`;
|
||||
return title.length > MAX_META_TITLE || full.length > MAX_FULL_TITLE
|
||||
? [`${route}: "${title}" tem ${title.length} caracteres`]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("não repete o nome do site dentro do título (o layout já o acrescenta)", async () => {
|
||||
const violations = (await collectRoutes()).flatMap(
|
||||
({ route, metadata }) => {
|
||||
const title = textOf(metadata.title);
|
||||
return /\||Evandro\.dev\.br/.test(title) ? [`${route}: ${title}`] : [];
|
||||
},
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it(`mantém a descrição em até ${MAX_DESCRIPTION} caracteres`, async () => {
|
||||
const violations = (await collectRoutes()).flatMap(
|
||||
({ route, metadata }) => {
|
||||
const description = textOf(metadata.description);
|
||||
return description.length > MAX_DESCRIPTION || description.length < 40
|
||||
? [`${route}: a descrição tem ${description.length} caracteres`]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("repete no cartão de compartilhamento o título e a descrição, dentro dos mesmos limites", async () => {
|
||||
for (const { route, metadata } of await collectRoutes()) {
|
||||
// o 404 não tem cartão próprio: só noindex
|
||||
if (route === "/not-found") continue;
|
||||
for (const card of [metadata.openGraph, metadata.twitter]) {
|
||||
expect(textOf(card?.title)).toBe(textOf(metadata.title));
|
||||
expect(textOf(card?.description)).toBe(textOf(metadata.description));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("não repete título nem descrição entre rotas", async () => {
|
||||
const routes = await collectRoutes();
|
||||
const titles = routes.map(({ metadata }) => textOf(metadata.title));
|
||||
const descriptions = routes.map(({ metadata }) =>
|
||||
textOf(metadata.description),
|
||||
);
|
||||
expect(new Set(titles).size).toBe(titles.length);
|
||||
expect(new Set(descriptions).size).toBe(descriptions.length);
|
||||
});
|
||||
|
||||
it("dá a cada página a própria canônica, e nenhuma ao 404", async () => {
|
||||
for (const { route, metadata } of await collectRoutes()) {
|
||||
if (route === "/not-found") {
|
||||
expect(metadata.alternates).toEqual({ canonical: null });
|
||||
} else {
|
||||
expect(metadata.alternates?.canonical).toBe(`${siteUrl}${route}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("título e descrição da home, que moram no layout", () => {
|
||||
// o `title.default` não passa pelo template do layout, então já leva o sufixo
|
||||
const homeTitle = (layoutMetadata.title as { default: string }).default;
|
||||
|
||||
it(`mantém o título da home em até ${MAX_FULL_TITLE} caracteres, sufixo incluído`, () => {
|
||||
expect(homeTitle.endsWith(TITLE_SUFFIX)).toBe(true);
|
||||
expect(homeTitle.length).toBeLessThanOrEqual(MAX_FULL_TITLE);
|
||||
});
|
||||
|
||||
it(`mantém a descrição da home em até ${MAX_DESCRIPTION} caracteres`, () => {
|
||||
const description = textOf(layoutMetadata.description);
|
||||
expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION);
|
||||
expect(description.length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("não repete o título nem a descrição da home em outra rota", async () => {
|
||||
const routes = await collectRoutes();
|
||||
const titles = routes.map(({ metadata }) => textOf(metadata.title));
|
||||
const descriptions = routes.map(({ metadata }) =>
|
||||
textOf(metadata.description),
|
||||
);
|
||||
expect(titles).not.toContain(homeTitle.slice(0, -TITLE_SUFFIX.length));
|
||||
expect(descriptions).not.toContain(textOf(layoutMetadata.description));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import NotFound, { metadata } from "./not-found";
|
||||
|
||||
describe("not-found", () => {
|
||||
it("has no canonical (the home one does not apply to a 404) and no generic robots tag of its own", () => {
|
||||
// o Next já escreve <meta name="robots" content="noindex"> no 404: repeti-lo duplicaria a tag
|
||||
expect(metadata.alternates).toEqual({ canonical: null });
|
||||
expect(metadata.robots).not.toHaveProperty("index");
|
||||
expect(metadata.robots).not.toHaveProperty("follow");
|
||||
});
|
||||
|
||||
it("keeps Googlebot out, since the root layout lets it index", () => {
|
||||
expect(metadata.robots).toEqual({
|
||||
googleBot: { index: false, follow: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the problem and offers a way back, in Portuguese", () => {
|
||||
render(<NotFound />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("404");
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Página não encontrada" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Voltar ao início" }),
|
||||
).toHaveAttribute("href", "/");
|
||||
expect(screen.getByRole("link", { name: "Ver os cases" })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases",
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-6
@@ -2,15 +2,13 @@ import { ArrowLeft, Code2, Home } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
// O layout raiz libera a indexação (inclusive para o Googlebot); aqui a regra inteira é trocada por noindex.
|
||||
// O Next já escreve <meta name="robots" content="noindex"> em toda página 404; repetir `robots` aqui duplicaria a tag.
|
||||
// O layout raiz libera a indexação para o Googlebot, então só essa regra é trocada. A canônica da home também não vale num 404.
|
||||
export const metadata: Metadata = {
|
||||
title: "Página não encontrada",
|
||||
description: "A página que você procura não existe ou foi movida.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
googleBot: { index: false, follow: false },
|
||||
},
|
||||
alternates: { canonical: null },
|
||||
robots: { googleBot: { index: false, follow: false } },
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
|
||||
@@ -4,7 +4,9 @@ import PrivacyPage, { metadata } from "./page";
|
||||
it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () => {
|
||||
const { container } = render(<PrivacyPage />);
|
||||
|
||||
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
|
||||
const breadcrumb = screen.getByRole("navigation", {
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
|
||||
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
|
||||
"Início",
|
||||
@@ -50,6 +52,18 @@ it("mentions no GitHub link and no English jargon in the visible text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same shell as the home, with no narrow max-width", () => {
|
||||
const { container } = render(<PrivacyPage />);
|
||||
|
||||
const shells = container.querySelectorAll(".ds-section-shell");
|
||||
expect(shells.length).toBeGreaterThan(0);
|
||||
for (const shell of shells) {
|
||||
expect(shell.className).not.toMatch(/max-w-/);
|
||||
}
|
||||
// o sufixo " | Evandro.dev.br" vem do layout: o título não o repete
|
||||
expect(metadata.title).toBe("Privacidade e consentimento");
|
||||
});
|
||||
|
||||
it("keeps the lime fill for contact actions only", () => {
|
||||
const { container } = render(<PrivacyPage />);
|
||||
|
||||
|
||||
@@ -4,19 +4,20 @@ import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import { pageTop } from "@/app/components/button-styles";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
import { siteName, siteUrl } from "@/lib/site-content";
|
||||
import { siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/privacidade`;
|
||||
const pageTitle = "Privacidade e consentimento";
|
||||
const pageDescription =
|
||||
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacidade e consentimento",
|
||||
description:
|
||||
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url: pageUrl,
|
||||
title: `Privacidade e consentimento | ${siteName}`,
|
||||
description:
|
||||
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
type: "website",
|
||||
}),
|
||||
};
|
||||
@@ -37,7 +38,7 @@ export default function PrivacyPage() {
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
<section className={`ds-section ds-section-dark ${pageTop}`}>
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<div className="ds-section-shell">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Início", href: "/" },
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { siteUrl } from "@/lib/site-content";
|
||||
import robots from "./robots";
|
||||
|
||||
describe("robots", () => {
|
||||
it("allows everything, points to the sitemap at the site address and lists no route that does not exist", () => {
|
||||
expect(robots()).toEqual({
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/_next/static/", "/_next/image/"],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
});
|
||||
});
|
||||
|
||||
it("has no host (Yandex only, deprecated) and blocks no /api/ or /admin/", () => {
|
||||
const result = robots();
|
||||
expect(result).not.toHaveProperty("host");
|
||||
expect(JSON.stringify(result)).not.toMatch(/\/api\/|\/admin\/|disallow/i);
|
||||
});
|
||||
|
||||
it("is saved without a byte order mark", () => {
|
||||
const bytes = readFileSync(join(__dirname, "robots.ts"));
|
||||
expect([...bytes.subarray(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]);
|
||||
});
|
||||
});
|
||||
+3
-4
@@ -1,4 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteUrl } from "@/lib/site-content";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
@@ -6,10 +7,8 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/_next/static/", "/_next/image/"],
|
||||
disallow: ["/api/", "/admin/"],
|
||||
},
|
||||
],
|
||||
sitemap: "https://evandro.dev.br/sitemap.xml",
|
||||
host: "https://evandro.dev.br",
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
caseStudies,
|
||||
getRelatedCases,
|
||||
getServicePage,
|
||||
isClientCase,
|
||||
servicePages,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import ServicesIndexPage from "../page";
|
||||
@@ -62,16 +64,16 @@ it("keeps static params and route metadata canonical", async () => {
|
||||
const url = `${siteUrl}/servicos/${service.slug}`;
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
},
|
||||
});
|
||||
@@ -192,7 +194,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
||||
]);
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
@@ -273,7 +275,7 @@ it("shows the client cases first, right after the hero, with the first cover in
|
||||
|
||||
const casesHeading = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Veja projetos parecidos, já prontos",
|
||||
name: "Onde já apliquei isso",
|
||||
});
|
||||
expect(
|
||||
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
|
||||
@@ -311,9 +313,7 @@ it("hides the related block and the hero frame when a service has no case", asyn
|
||||
const { container } = await renderService("tauri-rust");
|
||||
|
||||
expect(
|
||||
screen.queryByRole("heading", {
|
||||
name: "Veja projetos parecidos, já prontos",
|
||||
}),
|
||||
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
|
||||
expect(
|
||||
@@ -331,11 +331,122 @@ it("labels the Twins Lab prototype as a laboratory project on the IoT service",
|
||||
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/cases/twins-lab",
|
||||
]);
|
||||
expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible();
|
||||
expect(screen.getByText("Twins Lab · Projeto próprio")).toBeVisible();
|
||||
expect(screen.queryByText(/Protótipo de laboratório/)).toBeNull();
|
||||
// sem captura gravada, não há moldura nem imagem
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels only the laboratory projects as 'Projeto próprio'", async () => {
|
||||
for (const slug of ["criacao-de-sites", "integracao-hardware-software-iot"]) {
|
||||
const { unmount } = await renderService(slug);
|
||||
const related = getRelatedCases(getServicePage(slug)?.relatedCaseIds ?? []);
|
||||
expect(related.length).toBeGreaterThan(0);
|
||||
const labels = screen.queryAllByText(/· Projeto próprio$/);
|
||||
expect(labels).toHaveLength(related.filter((c) => !isClientCase(c)).length);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("says in one sentence, per case, why it matters to the service", async () => {
|
||||
for (const { slug, relatedCaseIds, caseRelevance } of servicePages) {
|
||||
if (relatedCaseIds.length === 0) continue;
|
||||
const { unmount } = await renderService(slug);
|
||||
|
||||
for (const id of relatedCaseIds) {
|
||||
const sentence = caseRelevance[id];
|
||||
expect(sentence).toBeTruthy();
|
||||
const card = screen.getByText(sentence).closest("article");
|
||||
expect(card).toContainElement(
|
||||
screen.getByRole("link", {
|
||||
name: `Ler case: ${caseStudies.find((c) => c.id === id)?.title}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("cites the Apolo Energia experience in text on the IoT and architecture pages, with no link", async () => {
|
||||
for (const slug of [
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]) {
|
||||
const note = getServicePage(slug)?.experienceNote ?? "";
|
||||
const { container, unmount } = await renderService(slug);
|
||||
|
||||
const paragraph = screen.getByText(/Apolo Energia/);
|
||||
expect(paragraph).toBeVisible();
|
||||
expect(paragraph.textContent).toContain(note);
|
||||
expect(paragraph.textContent).toMatch(/Desde 2022/);
|
||||
expect(paragraph.closest("a")).toBeNull();
|
||||
expect(paragraph.querySelector("a")).toBeNull();
|
||||
// dentro do bloco "Onde já apliquei isso", antes dos cards
|
||||
const block = screen.getByRole("heading", {
|
||||
name: "Onde já apliquei isso",
|
||||
});
|
||||
expect(isBefore(block, paragraph)).toBe(true);
|
||||
expect(
|
||||
isBefore(
|
||||
paragraph,
|
||||
container.querySelector("article.ds-card") as Element,
|
||||
),
|
||||
).toBe(true);
|
||||
const hrefs = screen
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href") ?? "");
|
||||
expect(hrefs.some((href) => /apolo/i.test(href))).toBe(false);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not mention Apolo Energia on the other service pages", async () => {
|
||||
for (const slug of [
|
||||
"criacao-de-sites",
|
||||
"engenharia-de-software",
|
||||
"consultoria-nextjs",
|
||||
"tauri-rust",
|
||||
]) {
|
||||
const { container, unmount } = await renderService(slug);
|
||||
expect(container.textContent).not.toMatch(/Apolo/);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the FAQ before the next step and ends every service page with a contact button", async () => {
|
||||
for (const { slug, faq, ctaLabel } of servicePages) {
|
||||
const { container, unmount } = await renderService(slug);
|
||||
|
||||
const nextStep = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Conte o que você precisa resolver.",
|
||||
});
|
||||
if (faq?.length) {
|
||||
const faqHeading = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Perguntas frequentes",
|
||||
});
|
||||
expect(isBefore(faqHeading, nextStep)).toBe(true);
|
||||
}
|
||||
|
||||
// o botão de contato vem depois das dúvidas: o último elemento da página é a seção do próximo passo
|
||||
const last = container.querySelector("main")?.lastElementChild;
|
||||
expect(last).toContainElement(nextStep);
|
||||
const cta = within(last as HTMLElement).getByRole("link", {
|
||||
name: `${ctaLabel} no WhatsApp`,
|
||||
});
|
||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
|
||||
"https://wa.me",
|
||||
);
|
||||
for (const question of faq ?? []) {
|
||||
expect(
|
||||
isBefore(screen.getByRole("heading", { name: question.question }), cta),
|
||||
).toBe(true);
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the offer block only when some field is filled in", async () => {
|
||||
const empty = await renderService("criacao-de-sites");
|
||||
expect(
|
||||
|
||||
@@ -51,11 +51,11 @@ export async function generateMetadata({
|
||||
const url = `${siteUrl}/servicos/${service.slug}`;
|
||||
|
||||
return {
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url,
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
type: "article",
|
||||
}),
|
||||
@@ -129,7 +129,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-sp-4 text-sm leading-6 text-white/70">
|
||||
<p className="mt-sp-4 text-[15px] md:text-sm leading-6 text-white/70">
|
||||
Fale diretamente com Evandro. Abre uma conversa, sem precisar
|
||||
preencher um formulário. Atendimento remoto a partir de Garuva,
|
||||
SC.
|
||||
@@ -139,7 +139,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ServiceRelatedCases cases={relatedCases} />
|
||||
<ServiceRelatedCases
|
||||
cases={relatedCases}
|
||||
relevance={service.caseRelevance}
|
||||
experienceNote={service.experienceNote}
|
||||
/>
|
||||
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
@@ -171,7 +175,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
<article className="min-w-0">
|
||||
<div className="ds-kicker">Da conversa à entrega</div>
|
||||
<h2 className="ds-card-title">Como trabalhamos</h2>
|
||||
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-sm leading-8 text-[var(--ink-80)]">
|
||||
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-[15px] md:text-sm leading-8 text-[var(--ink-80)]">
|
||||
{service.process.map((step) => (
|
||||
<li key={step} className="pl-sp-2">
|
||||
{step}
|
||||
@@ -224,7 +228,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
|
||||
<ServiceOffer service={service} />
|
||||
|
||||
<section className="ds-section ds-section-dark pb-sp-6">
|
||||
<ServiceFaq faq={service.faq} />
|
||||
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
|
||||
<div className="ds-kicker">Próximo passo</div>
|
||||
@@ -252,8 +258,6 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ServiceFaq faq={service.faq} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { caseStudies, isClientCase } from "@/lib/site-content";
|
||||
import { ServiceRelatedCases } from "./ServiceRelatedCases";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ServiceRelatedCases, direto no componente:
|
||||
* sem case nem experiência, só a experiência, case sem frase de relação, case de laboratório,
|
||||
* case sem captura e case sem site no ar. A existência dos arquivos em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||
getCaseMedia: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
function withDesktopCapture() {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
device === "desktop" ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const sev = getCase("sev-modaintima"); // cliente, com site no ar
|
||||
const asServicos = getCase("as-servicos"); // cliente, sem site público
|
||||
const twins = getCase("twins-lab"); // laboratório, sem site público
|
||||
const TITLE = "Onde já apliquei isso";
|
||||
const NOTE = "Texto de experiência de teste.";
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("ServiceRelatedCases", () => {
|
||||
it("path: no case and no experience note -> renders nothing", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("path: no case but an experience note -> keeps the block with the note and no case card", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={NOTE} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: TITLE }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(NOTE)).toBeVisible();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
|
||||
withDesktopCapture();
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases
|
||||
cases={[sev]}
|
||||
relevance={{ [sev.id]: "Frase de relação do SEV." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: TITLE }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Na prática:")).toBeNull();
|
||||
const card = container.querySelector("article");
|
||||
if (!card) throw new Error("Missing case card");
|
||||
expect(within(card).getByText(sev.headline)).toBeVisible();
|
||||
// case de cliente: o kicker é só o nome
|
||||
expect(within(card).getByText(sev.title)).toBeVisible();
|
||||
expect(within(card).queryByText(/Projeto próprio/)).toBeNull();
|
||||
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
|
||||
expect(
|
||||
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
).toHaveAttribute("href", `/cases/${sev.id}`);
|
||||
expect(
|
||||
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
|
||||
).toHaveAttribute("href", sev.demoUrl);
|
||||
});
|
||||
|
||||
it("path: cases and an experience note -> the note comes first, in text only, with no link", () => {
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[sev]}
|
||||
relevance={{ [sev.id]: "Frase de relação do SEV." }}
|
||||
experienceNote={NOTE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const note = screen.getByText(NOTE, { exact: false });
|
||||
expect(screen.getByText("Na prática:")).toBeVisible();
|
||||
expect(note.closest("a")).toBeNull();
|
||||
expect(note.querySelector("a")).toBeNull();
|
||||
expect(
|
||||
note.compareDocumentPosition(
|
||||
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("path: case with a desktop capture -> cover link with the poster, hidden from assistive tech", () => {
|
||||
withDesktopCapture();
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
|
||||
);
|
||||
|
||||
const coverLink = container.querySelector(
|
||||
`article a[aria-hidden="true"][href="/cases/${sev.id}"]`,
|
||||
);
|
||||
expect(coverLink).not.toBeNull();
|
||||
expect(coverLink).toHaveAttribute("tabindex", "-1");
|
||||
expect(coverLink?.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||
"alt",
|
||||
`Captura de tela do site ${sev.title}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: case without a desktop capture -> no cover link, text and buttons stay", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("a[aria-hidden='true']")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: laboratory case -> kicker marks it as 'Projeto próprio'", () => {
|
||||
expect(isClientCase(twins)).toBe(false);
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[twins]}
|
||||
relevance={{ [twins.id]: "Frase." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
|
||||
render(<ServiceRelatedCases cases={[sev]} relevance={{}} />);
|
||||
|
||||
const card = screen.getByRole("heading", { level: 3 }).closest("article");
|
||||
expect(card).not.toBeNull();
|
||||
expect(within(card as HTMLElement).getByText(sev.summary)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: case without a public site -> no 'Abrir o site' link", () => {
|
||||
expect(asServicos.demoUrl).toBeNull();
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[asServicos]}
|
||||
relevance={{ [asServicos.id]: "Frase." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import { ServiceRelatedCases } from "./ServiceRelatedCases";
|
||||
|
||||
const caseStudy = caseStudies[0];
|
||||
const note = "Desde 2022 trabalho com isso, em sistemas internos.";
|
||||
|
||||
it("renders nothing without cases and without an experience note", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows only the experience note, without the 'open the case' sentence, when there are no cases", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={note} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(note)).toBeVisible();
|
||||
expect(screen.queryByText(/Abra o case/)).toBeNull();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(container.querySelector(".ds-grid-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("invites to open the case and lists each case when there are some", () => {
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[caseStudy]}
|
||||
relevance={{ [caseStudy.id]: "Frase própria sobre a relação." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Abra o case/)).toBeVisible();
|
||||
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }),
|
||||
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
|
||||
});
|
||||
|
||||
it("falls back to the case summary when no relevance sentence is given", () => {
|
||||
render(<ServiceRelatedCases cases={[caseStudy]} relevance={{}} />);
|
||||
|
||||
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||
});
|
||||
@@ -1,16 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { CaseCover } from "@/app/cases/_components/CaseCover";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
|
||||
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
|
||||
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
|
||||
|
||||
/** "Veja projetos parecidos": os cases ligados ao serviço. Sem case relacionado, não mostra nada. */
|
||||
function ServiceCaseCard({
|
||||
caseStudy,
|
||||
relevance,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
/** Por que este case interessa ao serviço; sem frase própria, vale o resumo do case. */
|
||||
relevance?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<CaseCoverLink
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
/>
|
||||
<div className="ds-kicker">
|
||||
{isClientCase(caseStudy)
|
||||
? caseStudy.title
|
||||
: `${caseStudy.title} · Projeto próprio`}
|
||||
</div>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-kicker mt-sp-5">Relação com este serviço</p>
|
||||
<p className="ds-copy !mt-sp-2">{relevance ?? caseStudy.summary}</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<Link href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site: {caseStudy.title}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma frase sobre o que ele tem a ver com ele,
|
||||
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase
|
||||
* que manda abrir o case; sem case nem experiência, não mostra nada.
|
||||
*/
|
||||
export function ServiceRelatedCases({
|
||||
cases,
|
||||
relevance,
|
||||
experienceNote,
|
||||
}: {
|
||||
cases: readonly CaseStudyContent[];
|
||||
/** Por que cada case interessa a este serviço (chave: id do case). */
|
||||
relevance: Readonly<Record<string, string>>;
|
||||
experienceNote?: string;
|
||||
}) {
|
||||
if (cases.length === 0) return null;
|
||||
const hasCases = cases.length > 0;
|
||||
if (!hasCases && !experienceNote) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -23,61 +74,37 @@ export function ServiceRelatedCases({
|
||||
<div className="ds-section-meta">
|
||||
<p className="ds-eyebrow">Trabalhos parecidos</p>
|
||||
<h2 id="casos-relacionados-titulo" className="ds-section-title">
|
||||
Veja projetos parecidos, já prontos
|
||||
Onde já apliquei isso
|
||||
</h2>
|
||||
<p className="ds-section-description">
|
||||
Projetos reais nesta área. Abra o case para ver o que foi feito e
|
||||
como ficou.
|
||||
</p>
|
||||
{hasCases ? (
|
||||
<p className="ds-section-description">
|
||||
Cada projeto tem a ver com este serviço de um jeito. Abra o case
|
||||
para ver o que foi feito e como ficou.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ds-grid-2">
|
||||
{cases.map((caseStudy) => (
|
||||
<article key={caseStudy.id} className="ds-card">
|
||||
{getCaseMedia(caseStudy.id, "desktop") ? (
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="ds-kicker">
|
||||
{isClientCase(caseStudy)
|
||||
? caseStudy.title
|
||||
: `${caseStudy.title} · Protótipo de laboratório`}
|
||||
</div>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site: {caseStudy.title}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{experienceNote ? (
|
||||
<p className="ds-copy mb-sp-8 max-w-[72ch]">
|
||||
<strong className="font-semibold text-[var(--ink)]">
|
||||
Na prática:
|
||||
</strong>{" "}
|
||||
{experienceNote}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasCases ? (
|
||||
<div className="ds-grid-2">
|
||||
{cases.map((caseStudy) => (
|
||||
<ServiceCaseCard
|
||||
key={caseStudy.id}
|
||||
caseStudy={caseStudy}
|
||||
relevance={relevance[caseStudy.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+9
-20
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CaseCover } from "@/app/cases/_components/CaseCover";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
|
||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import {
|
||||
pageTop,
|
||||
@@ -15,9 +14,9 @@ import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/servicos`;
|
||||
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas";
|
||||
const pageTitle = "Sites, sistemas e aplicativos sob medida";
|
||||
const pageDescription =
|
||||
"Sites, sistemas web e aplicativos sob medida para sua empresa apresentar o negócio e organizar a operação. Evandro, desenvolvedor em Garuva-SC, atende Joinville, região e todo o Brasil de forma remota.";
|
||||
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Atendimento de Garuva-SC para todo o Brasil.";
|
||||
|
||||
const businessIntents = [
|
||||
{
|
||||
@@ -188,21 +187,11 @@ export default function ServicesIndexPage() {
|
||||
key={caseStudy.id}
|
||||
className="border-t border-[var(--border)] pt-sp-6"
|
||||
>
|
||||
{getCaseMedia(caseStudy.id, "desktop") ? (
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<CaseCoverLink
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
/>
|
||||
<p className="ds-kicker">{caseStudy.title}</p>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
@@ -241,7 +230,7 @@ export default function ServicesIndexPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="ds-card-title">Antes de contratar</h2>
|
||||
<dl className="mt-sp-5 space-y-sp-6 text-sm">
|
||||
<dl className="mt-sp-5 space-y-sp-6 text-[15px] md:text-sm">
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Quanto custa e quanto tempo leva?
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// "server-only" lança fora do servidor do Next; nos testes (jsdom) o módulo não faz nada.
|
||||
export {};
|
||||
+74
-8
@@ -1,16 +1,82 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { render } from "@testing-library/react";
|
||||
import { OgCard, readProjectOgBackground } from "./og-card";
|
||||
import { OG_IMAGE_MAX_BYTES, OgCard, readProjectOgImage } from "./og-card";
|
||||
import { PROJECT_VIDEO_IDS } from "./project-showcase";
|
||||
|
||||
describe("readProjectOgBackground", () => {
|
||||
it("returns the recorded cover of a showcase project as a JPEG data URI", () => {
|
||||
const uri = readProjectOgBackground("nutriscan");
|
||||
expect(uri).toMatch(/^data:image\/jpeg;base64,/);
|
||||
expect((uri ?? "").length).toBeGreaterThan(5_000);
|
||||
jest.mock("node:fs", () => {
|
||||
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
|
||||
return { ...actual, statSync: jest.fn(actual.statSync) };
|
||||
});
|
||||
|
||||
const mockedStatSync = jest.mocked(statSync);
|
||||
const actualStatSync =
|
||||
jest.requireActual<typeof import("node:fs")>("node:fs").statSync;
|
||||
|
||||
afterEach(() => {
|
||||
mockedStatSync.mockImplementation(actualStatSync);
|
||||
});
|
||||
|
||||
/** Largura e altura de um JPEG, lidas do primeiro marcador de quadro (SOF). */
|
||||
function jpegSize(image: Buffer) {
|
||||
let offset = 2;
|
||||
while (offset < image.length) {
|
||||
const marker = image[offset + 1];
|
||||
const length = image.readUInt16BE(offset + 2);
|
||||
if (
|
||||
marker >= 0xc0 &&
|
||||
marker <= 0xcf &&
|
||||
marker !== 0xc4 &&
|
||||
marker !== 0xc8
|
||||
) {
|
||||
return {
|
||||
height: image.readUInt16BE(offset + 5),
|
||||
width: image.readUInt16BE(offset + 7),
|
||||
};
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
throw new Error("JPEG sem marcador de quadro");
|
||||
}
|
||||
|
||||
describe("readProjectOgImage", () => {
|
||||
it("returns the recorded cover of a showcase project as it is, in JPEG", () => {
|
||||
const image = readProjectOgImage("nutriscan");
|
||||
expect(image?.subarray(0, 2).toString("hex")).toBe("ffd8");
|
||||
expect(image?.length).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
it("serves every recorded cover below 150 KB and in the 1200x630 card size", () => {
|
||||
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
|
||||
for (const id of PROJECT_VIDEO_IDS) {
|
||||
const image = readProjectOgImage(id);
|
||||
expect(image).toBeDefined();
|
||||
expect(image?.length ?? Infinity).toBeLessThan(150_000);
|
||||
expect(jpegSize(image as Buffer)).toEqual({ width: 1200, height: 630 });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for a cover that weighs the limit or more, so the plain card is used", () => {
|
||||
mockedStatSync.mockReturnValue({
|
||||
size: OG_IMAGE_MAX_BYTES,
|
||||
} as ReturnType<typeof statSync>);
|
||||
expect(readProjectOgImage("nutriscan")).toBeUndefined();
|
||||
|
||||
mockedStatSync.mockReturnValue({
|
||||
size: OG_IMAGE_MAX_BYTES - 1,
|
||||
} as ReturnType<typeof statSync>);
|
||||
expect(readProjectOgImage("nutriscan")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for projects without a recorded cover", () => {
|
||||
expect(readProjectOgBackground("twins-lab")).toBeUndefined();
|
||||
expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined();
|
||||
expect(readProjectOgImage("twins-lab")).toBeUndefined();
|
||||
expect(readProjectOgImage("../../etc/passwd")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the file is missing on disk", () => {
|
||||
mockedStatSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
expect(readProjectOgImage("nutriscan")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+17
-5
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import "server-only";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase";
|
||||
|
||||
@@ -8,13 +9,24 @@ const brandMark = `data:image/png;base64,${readFileSync(
|
||||
|
||||
export const OG_SIZE = { width: 1200, height: 630 } as const;
|
||||
export const OG_CONTENT_TYPE = "image/png";
|
||||
export const OG_PHOTO_CONTENT_TYPE = "image/jpeg";
|
||||
|
||||
/** Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), como data URI; `undefined` se não existir. */
|
||||
export function readProjectOgBackground(id: string): string | undefined {
|
||||
/** Teto de uma imagem de compartilhamento: quem recebe o link baixa a imagem a cada prévia, então ela precisa ser leve. */
|
||||
export const OG_IMAGE_MAX_BYTES = 150_000;
|
||||
|
||||
/**
|
||||
* Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), usada como está na imagem de compartilhamento.
|
||||
* `undefined` se não existir ou se pesar o teto ou mais: nesse caso quem chama gera o cartão simples, sem imagem de fundo.
|
||||
*/
|
||||
export function readProjectOgImage(id: string): Buffer | undefined {
|
||||
if (!PROJECT_VIDEO_IDS.includes(id)) return undefined;
|
||||
const path = join(process.cwd(), "public", getOgImagePath(id));
|
||||
if (!existsSync(path)) return undefined;
|
||||
return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`;
|
||||
try {
|
||||
if (statSync(path).size >= OG_IMAGE_MAX_BYTES) return undefined;
|
||||
return readFileSync(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function OgCard({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { portfolio } from "./portfolio-data";
|
||||
|
||||
describe("public/portfolio-data.json", () => {
|
||||
it("writes the Innovo company name with the correct accents and case", () => {
|
||||
const innovo = portfolio.experiences.find(
|
||||
({ id }) => id === "innovo-tecnologia",
|
||||
);
|
||||
expect(innovo?.company).toBe("Innovo Tecnologia da Informação Ltda.");
|
||||
expect(JSON.stringify(portfolio)).not.toMatch(/Informacao|Da Informa/);
|
||||
});
|
||||
|
||||
it("keeps the kind of every project, which separates client work from the laboratory", () => {
|
||||
expect(
|
||||
portfolio.projects.map(({ id, kind }) => [id, kind] as const),
|
||||
).toEqual([
|
||||
["sev-modaintima", "cliente"],
|
||||
["lavacao-047", "cliente"],
|
||||
["nutriscan", "laboratorio"],
|
||||
["twins-lab", "laboratorio"],
|
||||
["reddit-explorer", "laboratorio"],
|
||||
["sc-plus", "cliente"],
|
||||
["gcc-installer", "laboratorio"],
|
||||
["as-servicos", "cliente"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes each project in a subtitle a business owner understands", () => {
|
||||
const subtitles = Object.fromEntries(
|
||||
portfolio.projects.map(({ id, subtitle }) => [id, subtitle]),
|
||||
);
|
||||
expect(subtitles["sc-plus"]).toBe(
|
||||
"Site institucional com áreas, projetos e contato",
|
||||
);
|
||||
expect(subtitles["gcc-installer"]).toBe(
|
||||
"Instalador do compilador GCC para Windows",
|
||||
);
|
||||
expect(Object.values(subtitles).join(" ")).not.toMatch(
|
||||
/Next\.js|MongoDB|MinGW|Gêmeo Digital|gerada em código/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import "server-only";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
|
||||
+66
-1
@@ -1,15 +1,23 @@
|
||||
import sitemap from "../app/sitemap";
|
||||
import {
|
||||
buildBreadcrumbList,
|
||||
buildCaseJsonLd,
|
||||
buildPageSocialMetadata,
|
||||
buildPersonJsonLd,
|
||||
buildProfilePageJsonLd,
|
||||
buildServiceJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
getCaseImageUrl,
|
||||
SEO_IDS,
|
||||
twitterHandle,
|
||||
} from "./seo";
|
||||
import { caseStudies, servicePages, siteName, siteUrl } from "./site-content";
|
||||
import {
|
||||
caseStudies,
|
||||
isClientCase,
|
||||
servicePages,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "./site-content";
|
||||
|
||||
const representativeService = servicePages[0];
|
||||
if (!representativeService) throw new Error("Missing representative service");
|
||||
@@ -89,6 +97,63 @@ it("builds a canonical service with only the verified service area", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
it("describes a client case as a CreativeWork made by the person for the client", () => {
|
||||
const caseStudy = getCase("sev-modaintima");
|
||||
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
|
||||
expect(buildCaseJsonLd(caseStudy)).toEqual({
|
||||
"@type": "CreativeWork",
|
||||
"@id": `${pageUrl}#creative-work`,
|
||||
name: "SEV Moda Íntima: Catálogo digital e loja virtual",
|
||||
description: caseStudy.metaDescription,
|
||||
url: pageUrl,
|
||||
image: `${pageUrl}/opengraph-image/${caseStudy.id}`,
|
||||
inLanguage: "pt-BR",
|
||||
creator: { "@id": SEO_IDS.person },
|
||||
sourceOrganization: {
|
||||
"@type": "Organization",
|
||||
name: "SEV Moda Íntima",
|
||||
url: "https://sevmodaintima.com.br",
|
||||
},
|
||||
keywords:
|
||||
"Next.js 16, TypeScript, PostgreSQL, Prisma, Tailwind CSS 4, Framer Motion",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a CreativeWork for every case, with the client only on client work", () => {
|
||||
for (const caseStudy of caseStudies) {
|
||||
const work = buildCaseJsonLd(caseStudy);
|
||||
expect(work).toMatchObject({
|
||||
"@type": "CreativeWork",
|
||||
url: `${siteUrl}/cases/${caseStudy.id}`,
|
||||
image: getCaseImageUrl(caseStudy.id),
|
||||
creator: { "@id": SEO_IDS.person },
|
||||
});
|
||||
expect(work.name).toContain(caseStudy.title);
|
||||
expect(work.description.length).toBeGreaterThan(40);
|
||||
expect(work.keywords).toBe(caseStudy.stack.join(", "));
|
||||
expect("sourceOrganization" in work).toBe(isClientCase(caseStudy));
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the client address when a client case has no public site", () => {
|
||||
const work = buildCaseJsonLd(getCase("as-servicos")) as {
|
||||
url: string;
|
||||
sourceOrganization?: Record<string, unknown>;
|
||||
};
|
||||
expect(work.sourceOrganization).toStrictEqual({
|
||||
"@type": "Organization",
|
||||
name: "AS Serviços",
|
||||
});
|
||||
expect(work.url).toBe(`${siteUrl}/cases/as-servicos`);
|
||||
});
|
||||
|
||||
it("builds contiguous breadcrumbs from readonly tuples", () => {
|
||||
const pageUrl = `${siteUrl}/servicos/x`;
|
||||
const items = [
|
||||
|
||||
+38
-1
@@ -1,5 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { type ServicePageContent, siteName, siteUrl } from "./site-content";
|
||||
import {
|
||||
type CaseStudyContent,
|
||||
isClientCase,
|
||||
type ServicePageContent,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "./site-content";
|
||||
|
||||
export const twitterHandle = "@evandrodevbr";
|
||||
|
||||
@@ -78,6 +84,37 @@ export function buildServiceJsonLd(service: ServicePageContent) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
/** Imagem de compartilhamento do case: a mesma que as redes recebem em `og:image` (rota opengraph-image do case). */
|
||||
export function getCaseImageUrl(id: string) {
|
||||
return `${siteUrl}/cases/${id}/opengraph-image/${id}`;
|
||||
}
|
||||
|
||||
/** Trabalho do case como `CreativeWork`: autor ligado à pessoa e, nos projetos de cliente, o cliente em nome de quem foi feito. */
|
||||
export function buildCaseJsonLd(caseStudy: CaseStudyContent) {
|
||||
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
|
||||
return {
|
||||
"@type": "CreativeWork",
|
||||
"@id": `${pageUrl}#creative-work`,
|
||||
name: `${caseStudy.title}: ${caseStudy.subtitle}`,
|
||||
description: caseStudy.metaDescription,
|
||||
url: pageUrl,
|
||||
image: getCaseImageUrl(caseStudy.id),
|
||||
inLanguage: "pt-BR",
|
||||
creator: { "@id": SEO_IDS.person },
|
||||
...(isClientCase(caseStudy)
|
||||
? {
|
||||
sourceOrganization: {
|
||||
"@type": "Organization",
|
||||
name: caseStudy.title,
|
||||
...(caseStudy.demoUrl ? { url: caseStudy.demoUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
keywords: caseStudy.stack.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBreadcrumbList(
|
||||
pageUrl: string,
|
||||
items: readonly (readonly [name: string, url: string])[],
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(__dirname, "..");
|
||||
|
||||
describe("fronteira entre servidor e cliente", () => {
|
||||
it.each([
|
||||
"app/cases/_components/case-media.ts",
|
||||
"lib/og-card.tsx",
|
||||
"lib/portfolio-data.server.ts",
|
||||
])("%s só pode ser importado no servidor", (file) => {
|
||||
const source = readFileSync(join(root, file), "utf8");
|
||||
expect(source).toMatch(/^import "server-only";$/m);
|
||||
});
|
||||
|
||||
it("declara server-only como dependência", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
) as { dependencies: Record<string, string> };
|
||||
expect(manifest.dependencies["server-only"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("não deixa nenhum componente de cliente importar um módulo só de servidor", () => {
|
||||
const serverOnly =
|
||||
/from "(?:@\/lib\/og-card|@\/lib\/portfolio-data\.server|(?:\.{1,2}\/)+(?:[\w-]+\/)*case-media)"/;
|
||||
const clientFiles = [
|
||||
"app/cases/_components/CasePlayer.tsx",
|
||||
"app/curriculo/CvDownloadLink.tsx",
|
||||
];
|
||||
for (const file of clientFiles) {
|
||||
const source = readFileSync(join(root, file), "utf8");
|
||||
expect(source).toMatch(/^"use client";/);
|
||||
expect(source).not.toMatch(serverOnly);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import portfolioData from "@/public/portfolio-data.json";
|
||||
import {
|
||||
caseStudies,
|
||||
clientCases,
|
||||
@@ -77,7 +78,7 @@ it("leaves the commercial offer of every service empty for the owner to fill in"
|
||||
|
||||
it("mentions the region and a benefit in the title and description of every service", () => {
|
||||
for (const service of servicePages) {
|
||||
expect(`${service.seoTitle} ${service.metaDescription}`).toMatch(
|
||||
expect(`${service.metaTitle} ${service.metaDescription}`).toMatch(
|
||||
/Garuva|Joinville|Santa Catarina/,
|
||||
);
|
||||
expect(service.metaDescription).toMatch(/remoto|Brasil/);
|
||||
@@ -148,6 +149,89 @@ it("resolves every declared related case ID", () => {
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it("only cites project ids that exist in public/portfolio-data.json", () => {
|
||||
const projectIds = new Set(portfolioData.projects.map(({ id }) => id));
|
||||
const cited = [
|
||||
...caseStudies.map(({ id }) => id),
|
||||
...servicePages.flatMap(({ relatedCaseIds }) => relatedCaseIds),
|
||||
...servicePages.flatMap(({ caseRelevance }) => Object.keys(caseRelevance)),
|
||||
];
|
||||
|
||||
expect(cited.length).toBeGreaterThan(0);
|
||||
expect(cited.filter((id) => !projectIds.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes one sentence on why each related case matters to its service", () => {
|
||||
for (const service of servicePages) {
|
||||
expect(Object.keys(service.caseRelevance).sort()).toEqual(
|
||||
[...service.relatedCaseIds].sort(),
|
||||
);
|
||||
for (const sentence of Object.values(service.caseRelevance)) {
|
||||
expect(sentence.length).toBeGreaterThan(30);
|
||||
expect(sentence).toMatch(/[.]$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => {
|
||||
const apolo = portfolioData.experiences.find(
|
||||
({ id }) => id === "apolo-energia",
|
||||
);
|
||||
expect(apolo?.period).toMatch(/2022/);
|
||||
|
||||
const withNote = servicePages
|
||||
.filter(({ experienceNote }) => experienceNote)
|
||||
.map(({ slug }) => slug);
|
||||
expect(withNote).toEqual([
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]);
|
||||
for (const slug of withNote) {
|
||||
const note = getService(slug).experienceNote ?? "";
|
||||
expect(note).toMatch(/Apolo Energia/);
|
||||
expect(note).toMatch(/desde 2022/i);
|
||||
expect(note).toMatch(/sistemas internos/i);
|
||||
expect(note).toMatch(/aplicativos/i);
|
||||
expect(note).toMatch(/APIs/);
|
||||
// sem link e sem detalhe de operação do cliente
|
||||
expect(note).not.toMatch(/https?:|www\.|\.com/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
|
||||
for (const caseStudy of clientCases) {
|
||||
expect(caseStudy.problem.length).toBeGreaterThan(200);
|
||||
expect(caseStudy.problem).not.toMatch(/["“”]/);
|
||||
expect(caseStudy.problem).not.toMatch(/\d+\s?%|R\$\s?\d/);
|
||||
}
|
||||
expect(
|
||||
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
|
||||
).toMatch(/tamanho/);
|
||||
expect(
|
||||
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
|
||||
).toMatch(/WhatsApp/);
|
||||
});
|
||||
|
||||
it("leaves the optional facts of every case empty until there is real data", () => {
|
||||
for (const caseStudy of caseStudies) {
|
||||
expect(caseStudy.facts ?? []).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes project subtitles without technical jargon", () => {
|
||||
const jargon =
|
||||
/Next\.js|React|MongoDB|MinGW|\/GCC|tkinter|\bSSE\b|Three\.js|em código|r\/artificial/i;
|
||||
for (const caseStudy of caseStudies) {
|
||||
expect(caseStudy.subtitle).not.toMatch(jargon);
|
||||
}
|
||||
for (const project of portfolioData.projects) {
|
||||
expect(project.subtitle).not.toMatch(jargon);
|
||||
}
|
||||
expect(
|
||||
portfolioData.projects.find(({ id }) => id === "sc-plus")?.subtitle,
|
||||
).toBe("Site institucional com áreas, projetos e contato");
|
||||
});
|
||||
|
||||
it("does not publish the unavailable Twins Lab demo", () => {
|
||||
expect(
|
||||
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
|
||||
|
||||
+102
-27
@@ -8,7 +8,9 @@ export interface ServicePageContent {
|
||||
slug: string;
|
||||
navLabel: string;
|
||||
eyebrow: string;
|
||||
seoTitle: string;
|
||||
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
|
||||
metaTitle: string;
|
||||
/** Descrição para busca, com até 155 caracteres. */
|
||||
metaDescription: string;
|
||||
headline: string;
|
||||
intro: string;
|
||||
@@ -22,6 +24,10 @@ export interface ServicePageContent {
|
||||
ctaLabel: string;
|
||||
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
|
||||
relatedCaseIds: string[];
|
||||
/** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */
|
||||
caseRelevance: Record<string, string>;
|
||||
/** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */
|
||||
experienceNote?: string;
|
||||
/** Oferta: preencha só com valores reais. O bloco "Investimento e prazo" só aparece quando algum campo existe. */
|
||||
startingPrice?: string;
|
||||
typicalTimeline?: string;
|
||||
@@ -35,9 +41,13 @@ export interface CaseStudyContent {
|
||||
title: string;
|
||||
/** Tipo do projeto, em poucas palavras. */
|
||||
subtitle: string;
|
||||
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
|
||||
metaTitle: string;
|
||||
/** Descrição para busca, com até 155 caracteres. */
|
||||
metaDescription: string;
|
||||
/** Título da página, em linguagem de negócio. */
|
||||
headline: string;
|
||||
/** Resumo de uma ou duas frases (cards, descrição de busca e abertura da página). */
|
||||
/** Resumo de uma ou duas frases (cards e abertura da página). */
|
||||
summary: string;
|
||||
/** O que muda para quem usa, em uma frase. */
|
||||
impact: string;
|
||||
@@ -45,11 +55,13 @@ export interface CaseStudyContent {
|
||||
context: string;
|
||||
/** O problema que o projeto resolve. */
|
||||
problem: string;
|
||||
/** Fatos verificáveis do projeto, como "No ar desde ...". Só aparece na página quando há algum; sem dado real, fica de fora. */
|
||||
facts?: string[];
|
||||
/** O que foi feito, escrito como benefício. */
|
||||
deliveries: string[];
|
||||
/** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */
|
||||
result?: string[];
|
||||
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos no fim da página). */
|
||||
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */
|
||||
stack: string[];
|
||||
decisions: string[];
|
||||
demoUrl?: string | null;
|
||||
@@ -62,9 +74,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "criacao-de-sites",
|
||||
navLabel: "Sites profissionais",
|
||||
eyebrow: "Presença digital",
|
||||
seoTitle: "Criação de Sites para Empresas em Garuva e Joinville",
|
||||
metaTitle: "Criação de sites para empresas em Garuva",
|
||||
metaDescription:
|
||||
"Site profissional para apresentar sua empresa e levar o cliente ao seu WhatsApp. Desenvolvido em Garuva-SC, com atendimento em Joinville, região e remoto no Brasil.",
|
||||
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
headline: "Um site que explica seu negócio.",
|
||||
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.",
|
||||
@@ -99,6 +111,16 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
|
||||
ctaLabel: "Conversar sobre meu site",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um catálogo com busca e filtros que leva o pedido da cliente direto ao WhatsApp da loja.",
|
||||
"lavacao-047":
|
||||
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
|
||||
"sc-plus":
|
||||
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
|
||||
"as-servicos":
|
||||
"Um site institucional que conta duas frentes da empresa em uma só história, com cena 3D na rolagem.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
question: "Quanto custa e o que preciso ter pronto?",
|
||||
@@ -121,9 +143,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "engenharia-de-software",
|
||||
navLabel: "Sistemas web e aplicativos mobile",
|
||||
eyebrow: "Engenharia de software",
|
||||
seoTitle: "Sistemas Web e Aplicativos sob Medida em Santa Catarina",
|
||||
metaTitle: "Sistemas web e aplicativos sob medida",
|
||||
metaDescription:
|
||||
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas em um só lugar. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.",
|
||||
headline: "Sistemas e apps para o seu trabalho.",
|
||||
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.",
|
||||
@@ -158,6 +180,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
||||
ctaLabel: "Conversar sobre meu sistema ou app",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.",
|
||||
"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.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
question: "Preciso saber qual tecnologia usar?",
|
||||
@@ -180,9 +208,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "consultoria-nextjs",
|
||||
navLabel: "Melhorias em sites e sistemas Next.js",
|
||||
eyebrow: "Consultoria Next.js",
|
||||
seoTitle: "Consultoria Next.js para Melhorar Seu Site ou Sistema",
|
||||
metaTitle: "Consultoria Next.js para site ou sistema",
|
||||
metaDescription:
|
||||
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Atendimento a partir de Garuva-SC, em Joinville e remoto no Brasil.",
|
||||
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Garuva-SC, Joinville e remoto no Brasil.",
|
||||
headline: "Seu projeto Next.js pode funcionar melhor.",
|
||||
answer:
|
||||
"Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.",
|
||||
@@ -217,6 +245,14 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
|
||||
ctaLabel: "Conversar sobre meu projeto Next.js",
|
||||
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Um projeto em Next.js 16 com catálogo, busca, estoque e painel, que conheço por dentro.",
|
||||
"lavacao-047":
|
||||
"Um projeto em Next.js 16 em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
|
||||
"sc-plus":
|
||||
"Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.",
|
||||
},
|
||||
faq: [
|
||||
{
|
||||
question: "Não sei se meu site usa Next.js. Posso conversar?",
|
||||
@@ -239,9 +275,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "tauri-rust",
|
||||
navLabel: "Aplicativos desktop para Windows e Linux",
|
||||
eyebrow: "Aplicativos desktop",
|
||||
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
|
||||
metaTitle: "Aplicativos desktop para Windows e Linux",
|
||||
metaDescription:
|
||||
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da sua empresa. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.",
|
||||
headline: "Aplicativos para Windows e Linux.",
|
||||
answer:
|
||||
"Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são as plataformas atendidas; as versões e distribuições compatíveis são combinadas em cada projeto.",
|
||||
@@ -281,6 +317,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
ctaLabel: "Conversar sobre meu aplicativo desktop",
|
||||
relatedCaseIds: [],
|
||||
caseRelevance: {},
|
||||
faq: [
|
||||
{
|
||||
question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
|
||||
@@ -303,9 +340,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "integracao-hardware-software-iot",
|
||||
navLabel: "Integração de equipamentos e sistemas",
|
||||
eyebrow: "Hardware, software e IoT",
|
||||
seoTitle: "Integração de Equipamentos e Sistemas (IoT) em Santa Catarina",
|
||||
metaTitle: "Integração de equipamentos e sistemas (IoT)",
|
||||
metaDescription:
|
||||
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
|
||||
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva-SC, Joinville e remoto no Brasil.",
|
||||
headline: "Dados dos equipamentos no seu sistema.",
|
||||
answer:
|
||||
"Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro vemos o que cada dispositivo permite acessar; a integração segue essas interfaces e os requisitos de segurança e funcionamento.",
|
||||
@@ -340,6 +377,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"],
|
||||
ctaLabel: "Avaliar minha integração",
|
||||
relatedCaseIds: ["twins-lab"],
|
||||
caseRelevance: {
|
||||
"twins-lab":
|
||||
"Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.",
|
||||
},
|
||||
experienceNote:
|
||||
"Desde 2022 trabalho com isso na Apolo Energia: sistemas internos, aplicativos, APIs e integração de dispositivos IoT para a troca de dados entre serviços e parceiros. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
|
||||
faq: [
|
||||
{
|
||||
question: "Qualquer equipamento pode ser conectado?",
|
||||
@@ -362,10 +405,9 @@ export const servicePages: ServicePageContent[] = [
|
||||
slug: "arquitetura-de-software",
|
||||
navLabel: "Diagnóstico e arquitetura de software",
|
||||
eyebrow: "Diagnóstico e decisão",
|
||||
seoTitle:
|
||||
"Diagnóstico e Arquitetura de Software para Decidir com Segurança",
|
||||
metaTitle: "Diagnóstico e arquitetura de software",
|
||||
metaDescription:
|
||||
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano de ação, de Garuva-SC para Joinville e todo o Brasil.",
|
||||
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano, de Garuva-SC para todo o Brasil.",
|
||||
headline: "Entenda o problema antes de mudar tudo.",
|
||||
answer:
|
||||
"Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.",
|
||||
@@ -406,6 +448,12 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
ctaLabel: "Conversar sobre um diagnóstico",
|
||||
relatedCaseIds: ["sev-modaintima"],
|
||||
caseRelevance: {
|
||||
"sev-modaintima":
|
||||
"Uma loja completa, com catálogo, pedidos, estoque e painel, em que as decisões técnicas estão explicadas no case.",
|
||||
},
|
||||
experienceNote:
|
||||
"Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
|
||||
faq: [
|
||||
{
|
||||
question: "Preciso ter uma equipe técnica para contratar?",
|
||||
@@ -431,6 +479,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "sev-modaintima",
|
||||
title: "SEV Moda Íntima",
|
||||
subtitle: "Catálogo digital e loja virtual",
|
||||
metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp",
|
||||
metaDescription:
|
||||
"Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.",
|
||||
headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp",
|
||||
summary:
|
||||
"Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.",
|
||||
@@ -439,7 +490,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
context:
|
||||
"A SEV Moda Íntima vende moda íntima pela internet. O site funciona como vitrine e loja virtual: apresenta os produtos, recebe os pedidos pelo WhatsApp e dá à loja um painel para cuidar do catálogo.",
|
||||
problem:
|
||||
"Uma loja de moda íntima tem muitos produtos, em vários modelos e tamanhos. A cliente precisa achar o que procura com facilidade e fechar o pedido sem complicação. A loja precisa manter catálogo, estoque e pedidos organizados.",
|
||||
"A loja vende moda íntima em várias peças e tamanhos. A cliente precisa achar o que procura entre as opções e escolher a medida certa, e o pedido segue pelo WhatsApp, com o frete calculado pela distância. A loja, por sua vez, precisa saber o que ainda tem de cada tamanho.",
|
||||
deliveries: [
|
||||
"Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
|
||||
"Filtros e busca inteligente: a cliente encontra o que procura e os itens mais relevantes aparecem primeiro.",
|
||||
@@ -468,6 +519,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "lavacao-047",
|
||||
title: "Lavação 047",
|
||||
subtitle: "Site e painel para lavação automotiva",
|
||||
metaTitle: "Lavação 047: site com preços e WhatsApp",
|
||||
metaDescription:
|
||||
"Site da Lavação 047, em Garuva-SC: preço pelo porte do veículo, pedido pelo WhatsApp e painel para o dono editar tudo sozinho.",
|
||||
headline:
|
||||
"Site com tabela de preços e pedido pelo WhatsApp para uma lavação em Garuva",
|
||||
summary:
|
||||
@@ -477,7 +531,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
context:
|
||||
"A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.",
|
||||
problem:
|
||||
"Quem procura uma lavação quer saber quanto custa para o seu veículo e como chegar. O dono, por sua vez, precisa manter preços, textos e fotos em dia sem depender de alguém a cada mudança.",
|
||||
"O cliente chega com perguntas diretas: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.",
|
||||
deliveries: [
|
||||
"Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.",
|
||||
"Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.",
|
||||
@@ -508,6 +562,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "nutriscan",
|
||||
title: "NutriScan",
|
||||
subtitle: "Consulta de produtos por nome e código de barras",
|
||||
metaTitle: "NutriScan: consulta de alimentos",
|
||||
metaDescription:
|
||||
"Consulta de alimentos por nome ou código de barras, com cache próprio e a base pública Open Food Facts. Projeto próprio, no ar para teste.",
|
||||
headline: "Consulta de alimentos por nome ou código de barras",
|
||||
summary:
|
||||
"Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando um cache próprio com a base pública Open Food Facts.",
|
||||
@@ -534,7 +591,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "twins-lab",
|
||||
title: "Twins Lab",
|
||||
subtitle: "Protótipo de laboratório de gêmeo digital industrial",
|
||||
subtitle: "Painel ao vivo de um reator simulado",
|
||||
metaTitle: "Twins Lab: painel de reator simulado",
|
||||
metaDescription:
|
||||
"Protótipo de painel ao vivo que mostra a simulação de um reator industrial, com acesso por login e histórico guardado. Projeto próprio.",
|
||||
headline:
|
||||
"Painel ao vivo para acompanhar a simulação de um reator industrial",
|
||||
summary:
|
||||
@@ -561,7 +621,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "reddit-explorer",
|
||||
title: "Reddit Posts Explorer",
|
||||
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
subtitle: "Posts de um fórum de IA, filtrados por período",
|
||||
metaTitle: "Reddit Posts Explorer: coleta e filtros",
|
||||
metaDescription:
|
||||
"Coleta os posts em alta do r/artificial sem duplicar e permite filtrar por período e ordenar por votos ou comentários. Projeto próprio.",
|
||||
headline:
|
||||
"Posts de um fórum de inteligência artificial, coletados e filtrados por período",
|
||||
summary:
|
||||
@@ -588,7 +651,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "sc-plus",
|
||||
title: "SC Plus Aquicultura",
|
||||
subtitle: "Página institucional",
|
||||
subtitle: "Site institucional com áreas, projetos e contato",
|
||||
metaTitle: "SC Plus Aquicultura: página institucional",
|
||||
metaDescription:
|
||||
"Página institucional da SC Plus Aquicultura, com áreas de atuação, projetos, apoiadores e contato, preparada para os buscadores.",
|
||||
headline:
|
||||
"Página institucional para a SC Plus Aquicultura ser encontrada na internet",
|
||||
summary:
|
||||
@@ -596,9 +662,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
impact:
|
||||
"A instituição ganhou uma página própria, preparada para ser encontrada nos buscadores.",
|
||||
context:
|
||||
"A SC Plus Aquicultura é uma instituição do setor de aquicultura. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
|
||||
"A SC Plus Aquicultura é uma instituição de Garuva, em Santa Catarina, que atua com educação, ciência e tecnologia sustentável. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
|
||||
problem:
|
||||
"Sem uma página própria, quem procura a SC Plus na internet não encontra uma apresentação clara do que a instituição faz. Faltava um endereço próprio, com as áreas, os projetos, os apoiadores e o contato.",
|
||||
"A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.",
|
||||
deliveries: [
|
||||
"Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.",
|
||||
"Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
|
||||
@@ -616,7 +682,10 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "gcc-installer",
|
||||
title: "MinGW GCC Installer",
|
||||
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
subtitle: "Instalador do compilador GCC para Windows",
|
||||
metaTitle: "MinGW GCC Installer: instalador no Windows",
|
||||
metaDescription:
|
||||
"Aplicativo para Windows que lista as versões do MinGW-w64, instala o compilador GCC e ajusta o PATH, tudo em uma janela. Projeto próprio.",
|
||||
headline: "Instalador do compilador GCC para Windows em uma única janela",
|
||||
summary:
|
||||
"Aplicativo para Windows que lista as versões do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e deixa o GCC pronto para uso, tudo em uma janela.",
|
||||
@@ -644,6 +713,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "as-servicos",
|
||||
title: "AS Serviços",
|
||||
subtitle: "Site institucional com cena 3D",
|
||||
metaTitle: "AS Serviços: site com cena 3D",
|
||||
metaDescription:
|
||||
"Site institucional da AS Serviços com uma cena 3D que acompanha a rolagem e conta a engenharia física e o software numa só história.",
|
||||
headline: "Site institucional com uma cena 3D que acompanha a rolagem",
|
||||
summary:
|
||||
"Site institucional da AS Serviços com uma cena 3D criada em código: uma casa e uma placa-mãe desenhadas em linhas que reagem à rolagem da página.",
|
||||
@@ -652,7 +724,7 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
context:
|
||||
"A AS Serviços reúne engenharia física e software. O site precisava mostrar as duas frentes na mesma história.",
|
||||
problem:
|
||||
"Como contar, em uma única página, duas frentes tão diferentes quanto a engenharia física e o software, de um jeito que prenda a atenção de quem rola a página?",
|
||||
"A empresa reúne duas frentes bem diferentes: a engenharia física, de um lado, e o software, do outro. Em um site comum, as duas podem virar uma lista de serviços sem relação entre si. O desafio era mostrar as duas na mesma história, de um jeito que prenda a atenção de quem rola a página e que continue leve em celulares e conexões mais simples.",
|
||||
deliveries: [
|
||||
"Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.",
|
||||
"As duas frentes da empresa, a física e a digital, aparecem na mesma história visual.",
|
||||
@@ -670,10 +742,13 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const clientProjectIds = new Set(
|
||||
portfolio.projects.filter(isClientProject).map(({ id }) => id),
|
||||
);
|
||||
|
||||
/** Projetos feitos para clientes ficam à frente; os pessoais e de estudo vão para o Laboratório. */
|
||||
export function isClientCase(caseStudy: { id: string }) {
|
||||
const project = portfolio.projects.find(({ id }) => id === caseStudy.id);
|
||||
return project ? isClientProject(project) : false;
|
||||
return clientProjectIds.has(caseStudy.id);
|
||||
}
|
||||
|
||||
export const clientCases = caseStudies.filter(isClientCase);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"id": "innovo-tecnologia",
|
||||
"title": "Desenvolvedor Fullstack",
|
||||
"company": "Innovo Tecnologia Da Informacao Ltda.",
|
||||
"company": "Innovo Tecnologia da Informação Ltda.",
|
||||
"period": "Dezembro 2024 - Fevereiro 2025",
|
||||
"location": "Joinville, Santa Catarina, Brasil",
|
||||
"type": "Tempo Integral · Híbrido",
|
||||
@@ -166,7 +166,7 @@
|
||||
"id": "twins-lab",
|
||||
"kind": "laboratorio",
|
||||
"title": "Twins Lab",
|
||||
"subtitle": "Protótipo de Gêmeo Digital Industrial",
|
||||
"subtitle": "Painel ao vivo de um reator simulado",
|
||||
"description": "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e transmissão de eventos do servidor (SSE) a cada dois segundos.",
|
||||
"longDescription": "A rota exige sessão do NextAuth antes de abrir a transmissão SSE. O simulador salva dados e estado no MongoDB e notifica clientes quando execução ou cenário mudam.",
|
||||
"technologies": ["Next.js", "TypeScript", "NextAuth", "MongoDB", "SSE"],
|
||||
@@ -191,7 +191,7 @@
|
||||
"id": "reddit-explorer",
|
||||
"kind": "laboratorio",
|
||||
"title": "Reddit Posts Explorer",
|
||||
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
"subtitle": "Posts de um fórum de IA, filtrados por período",
|
||||
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB sem duplicar, pelo id, e permite filtrar por período e ordenar por votos ou comentários.",
|
||||
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação.",
|
||||
"technologies": [
|
||||
@@ -224,7 +224,7 @@
|
||||
"id": "sc-plus",
|
||||
"kind": "cliente",
|
||||
"title": "SC Plus Aquicultura",
|
||||
"subtitle": "Página institucional em Next.js 15",
|
||||
"subtitle": "Site institucional com áreas, projetos e contato",
|
||||
"description": "Página institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
|
||||
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
|
||||
"technologies": [
|
||||
@@ -254,7 +254,7 @@
|
||||
"id": "gcc-installer",
|
||||
"kind": "laboratorio",
|
||||
"title": "MinGW GCC Installer",
|
||||
"subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows",
|
||||
"subtitle": "Instalador do compilador GCC para Windows",
|
||||
"description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o compilador ao PATH do usuário no registro do Windows.",
|
||||
"longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Feito para Windows, com o 7-Zip no caminho padrão.",
|
||||
"technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
|
||||
@@ -281,7 +281,7 @@
|
||||
"id": "as-servicos",
|
||||
"kind": "cliente",
|
||||
"title": "AS Serviços",
|
||||
"subtitle": "Site institucional com cena 3D gerada em código",
|
||||
"subtitle": "Site institucional com cena 3D na rolagem",
|
||||
"description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa desenhada em linhas e uma placa-mãe ligadas ao progresso da rolagem.",
|
||||
"longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, preferência por menos movimento, economia de dados, conexão e capacidade do dispositivo.",
|
||||
"technologies": ["Next.js 16", "TypeScript", "Three.js"],
|
||||
|
||||
Reference in new issue
Block a user