diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx
index eb66b10..6c0da0e 100644
--- a/app/cases/[slug]/page.test.tsx
+++ b/app/cases/[slug]/page.test.tsx
@@ -51,7 +51,8 @@ function readJsonLdEntities(container: HTMLElement): Record[] {
/**
* 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".
+ * Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma
+ * frase nos dois, a do cabeçalho: "um projeto parecido com o {título}".
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const hrefs = screen
@@ -69,9 +70,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
- isClient
- ? `um projeto parecido com o da ${caseStudy.title}`
- : `um problema parecido com o do projeto ${caseStudy.title}`,
+ `um projeto parecido com o ${caseStudy.title}`,
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
@@ -590,16 +589,36 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
clientCaseIds.map((id) => `/cases/${id}`),
);
- // laboratório: sem capa
- const labLinks = screen.getAllByRole("link", { name: "Ver o projeto" });
+ // laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
+ const labLinks = screen.getAllByRole("link", { name: /^Ver o projeto / });
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`),
);
- for (const link of labLinks) {
- const card = link.closest("article");
+ const labList = labLinks[0].closest("ul");
+ expect(labList?.querySelectorAll(":scope > li")).toHaveLength(
+ labCaseIds.length,
+ );
+ expect(labList?.querySelector("article, .ds-card, .ds-bframe")).toBeNull();
+ labLinks.forEach((link, index) => {
+ const row = link.closest("li");
+ const caseStudy = getCase(labCaseIds[index]);
expect(isBefore(labHeading, link)).toBe(true);
- expect(card?.querySelector(".ds-bframe")).toBeNull();
- }
+ expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`);
+ expect(
+ within(row as HTMLElement).getByRole("heading", {
+ level: 3,
+ name: caseStudy.title,
+ }),
+ ).toBeVisible();
+ expect(
+ within(row as HTMLElement).getByText(caseStudy.subtitle),
+ ).toBeVisible();
+ expect(
+ within(row as HTMLElement).getByText("Projeto próprio"),
+ ).toBeVisible();
+ // sem o resumo longo do cartão antigo
+ expect(row).not.toHaveTextContent(caseStudy.summary);
+ });
// contato pelo WhatsApp, sem /#contact nem GitHub
const hrefs = screen
@@ -639,10 +658,11 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
};
const visibleItems = [...clientLinks, ...labLinks].map((link, index) => {
const href = link.getAttribute("href");
- const card = link.closest("article");
+ // cartão do cliente ou linha da lista do laboratório
+ const card = link.closest("article, li");
if (!card) throw new Error(`Missing visible card for ${href}`);
const expected = getCase((href ?? "").replace("/cases/", ""));
- expect(card.querySelector(".ds-kicker")).toHaveTextContent(expected.title);
+ expect(card).toHaveTextContent(expected.title);
return {
"@type": "ListItem",
position: index + 1,
diff --git a/app/cases/_components/CaseContactSection.paths.test.tsx b/app/cases/_components/CaseContactSection.paths.test.tsx
index dc73ded..4a0add8 100644
--- a/app/cases/_components/CaseContactSection.paths.test.tsx
+++ b/app/cases/_components/CaseContactSection.paths.test.tsx
@@ -4,7 +4,8 @@ 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.
+ * um projeto de laboratório ("problema parecido" no botão), o link para a lista de cases e os serviços ligados.
+ * A mensagem do WhatsApp é a mesma frase nos dois: "um projeto parecido com o {título}".
*/
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
@@ -29,7 +30,7 @@ describe("CaseContactSection", () => {
});
expect(cta).toHaveAttribute("target", "_blank");
expect(messageOf(cta)).toContain(
- `Quero conversar sobre um projeto parecido com o da ${sev.title}.`,
+ `Quero conversar sobre um projeto parecido com o ${sev.title}.`,
);
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
expect(
@@ -49,9 +50,11 @@ describe("CaseContactSection", () => {
const cta = screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
});
+ // a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
- `Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`,
+ `Quero conversar sobre um projeto parecido com o ${nutri.title}.`,
);
+ expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
diff --git a/app/cases/_components/CaseContactSection.tsx b/app/cases/_components/CaseContactSection.tsx
index 011cc40..3c908b3 100644
--- a/app/cases/_components/CaseContactSection.tsx
+++ b/app/cases/_components/CaseContactSection.tsx
@@ -1,24 +1,25 @@
-import Link from "next/link";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import type { CaseStudyContent } from "@/lib/site-content";
+import { similarProjectSubject } from "@/lib/whatsapp";
import { CaseRelatedServices } from "./CaseRelatedServices";
-/** Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido. */
+/**
+ * Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido.
+ * O assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`.
+ */
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;
@@ -47,15 +48,15 @@ export function CaseContactSection({
{copy.action}
-
+
Ver outros cases
-
+
diff --git a/app/cases/_components/CaseCover.paths.test.tsx b/app/cases/_components/CaseCover.paths.test.tsx
index 87ac2d0..f9d9495 100644
--- a/app/cases/_components/CaseCover.paths.test.tsx
+++ b/app/cases/_components/CaseCover.paths.test.tsx
@@ -1,11 +1,11 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
-import { CaseCover } from "./CaseCover";
+import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
- * e os valores padrão de `sizes` e `priority`.
+ * e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
@@ -49,16 +49,26 @@ describe("CaseCover", () => {
render();
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
- expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
+ expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
+ expect(image).not.toHaveAttribute("fetchpriority");
});
- it("path: sizes and priority given -> uses them", () => {
+ it("path: sizes and priority given -> uses them, with eager loading and high fetch priority", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render();
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
- expect(image).not.toHaveAttribute("loading", "lazy");
+ expect(image).toHaveAttribute("loading", "eager");
+ expect(image).toHaveAttribute("fetchpriority", "high");
+ });
+
+ it("path: no 100vw sizes -> every preset describes the width the cover really has", () => {
+ // 100vw baixava a imagem de 750 px para uma capa de 300 px no celular
+ for (const sizes of Object.values(coverSizes)) {
+ expect(sizes).not.toMatch(/(^|, )100vw$/);
+ expect(sizes).toMatch(/calc\(100vw - \d+px\)$/);
+ }
});
});
diff --git a/app/cases/_components/CaseCover.tsx b/app/cases/_components/CaseCover.tsx
index 2a7a6c3..6e88382 100644
--- a/app/cases/_components/CaseCover.tsx
+++ b/app/cases/_components/CaseCover.tsx
@@ -5,12 +5,40 @@ import {
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
-/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */
+/**
+ * Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
+ * (com 100vw, um celular de 390 px pedia a versão de 750 px para uma capa de 300 px). As contas seguem o
+ * padding das seções (20 px até 768 px; depois 6vw, no máximo 64 px), a coluna central de 1240 px e os cartões
+ * (borda e 20 a 28 px de padding).
+ */
+export const coverSizes = {
+ /** Topo do case e do serviço: coluna da direita a partir de 1024 px, e a largura da página abaixo disso. */
+ hero: "(min-width: 1440px) 560px, (min-width: 1024px) 40vw, (min-width: 769px) 88vw, calc(100vw - 40px)",
+ /** Capa dentro de um cartão (`ds-card`) da grade de duas colunas, que vira uma só até 1024 px. */
+ card: "(min-width: 1440px) 560px, (min-width: 1025px) calc(50vw - 134px), (min-width: 769px) calc(88vw - 56px), calc(100vw - 88px)",
+ /** Capa solta numa grade de duas colunas, sem cartão em volta (a partir de 768 px). */
+ column:
+ "(min-width: 1440px) 604px, (min-width: 1068px) calc(50vw - 80px), (min-width: 768px) calc(44vw - 16px), calc(100vw - 40px)",
+} as const;
+
+/**
+ * Props da imagem que abre a página (a maior acima da dobra): carrega na hora e com prioridade de rede alta.
+ * No Next 16 a prop `priority` do `Image` só gera o ``; a prioridade de rede vem do `fetchPriority`.
+ */
+export const priorityImageProps = {
+ loading: "eager",
+ fetchPriority: "high",
+} as const;
+
+/**
+ * Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada.
+ * `priority` é para a imagem que abre a página: aplica `priorityImageProps`.
+ */
export function CaseCover({
id,
title,
demoUrl,
- sizes = "(min-width: 1024px) 560px, 100vw",
+ sizes = coverSizes.card,
priority = false,
}: {
id: string;
@@ -21,6 +49,7 @@ export function CaseCover({
}) {
const media = getCaseMedia(id, "desktop");
if (!media) return null;
+ const priorityProps = priority ? priorityImageProps : {};
return (
@@ -30,7 +59,7 @@ export function CaseCover({
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
- priority={priority}
+ {...priorityProps}
className="object-cover object-top"
/>
diff --git a/app/cases/_components/CaseCoverLink.paths.test.tsx b/app/cases/_components/CaseCoverLink.paths.test.tsx
index ec5226a..ff55960 100644
--- a/app/cases/_components/CaseCoverLink.paths.test.tsx
+++ b/app/cases/_components/CaseCoverLink.paths.test.tsx
@@ -1,11 +1,13 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
+import { coverSizes } from "./CaseCover";
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.
+ * com a capa dentro), os valores padrão de `sizes` e `priority`, o pré-carregamento só ao chegar no link
+ * e os valores dados.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
@@ -61,32 +63,34 @@ describe("CaseCoverLink", () => {
).toBeInTheDocument();
});
- it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => {
+ it("path: sizes and priority omitted -> default sizes and lazy image", () => {
+ mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
+ render();
+
+ const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
+ expect(image).toHaveAttribute("sizes", coverSizes.card);
+ expect(image).toHaveAttribute("loading", "lazy");
+ expect(image).not.toHaveAttribute("fetchpriority");
+ });
+
+ it("path: link -> pre-fetches the case only when the pointer or focus reaches it", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render();
- 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",
- );
+ const link = container.querySelector("a");
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link as HTMLAnchorElement);
+ expect(link).toHaveAttribute("data-prefetch", "null");
});
- it("path: sizes, priority and prefetch given -> all of them are used", () => {
+ it("path: sizes and priority given -> both are used", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
- const { container } = render(
- ,
- );
+ render();
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",
- );
+ expect(image).toHaveAttribute("loading", "eager");
+ expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
diff --git a/app/cases/_components/CaseCoverLink.tsx b/app/cases/_components/CaseCoverLink.tsx
index 18cc825..e9fc933 100644
--- a/app/cases/_components/CaseCoverLink.tsx
+++ b/app/cases/_components/CaseCoverLink.tsx
@@ -1,34 +1,32 @@
-import Link from "next/link";
-import { CaseCover } from "./CaseCover";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
+import { CaseCover, coverSizes } 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.
+ * A rota do case só é pré-carregada quando a pessoa chega no link (HoverPrefetchLink).
*/
export function CaseCoverLink({
id,
title,
demoUrl,
- sizes = "(min-width: 1024px) 560px, 100vw",
+ sizes = coverSizes.card,
priority = false,
- prefetch,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
- prefetch?: boolean;
}) {
if (!getCaseMedia(id, "desktop")) return null;
return (
-
-
+
);
}
diff --git a/app/cases/_components/CaseHero.paths.test.tsx b/app/cases/_components/CaseHero.paths.test.tsx
index d6a690e..511d038 100644
--- a/app/cases/_components/CaseHero.paths.test.tsx
+++ b/app/cases/_components/CaseHero.paths.test.tsx
@@ -3,6 +3,7 @@ import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseHero } from "./CaseHero";
import { getCaseMedia } from "./case-media";
+import { compactHero } from "./hero-styles";
/**
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
@@ -77,6 +78,27 @@ describe("CaseHero: captura gravada e tipo de case", () => {
expect(screen.getByText(lab.summary)).toBeVisible();
});
+ it("path: with a capture -> the top strip keeps the default height", () => {
+ withCapture(true);
+ const { container } = render();
+
+ expect(container.querySelector("section")).not.toHaveClass(compactHero);
+ });
+
+ it("path: without a capture -> the top strip is the lower one-column strip, for client and laboratory cases", () => {
+ withCapture(false);
+ for (const [caseStudy, isClient] of [
+ [client, true],
+ [lab, false],
+ ] as const) {
+ const { container, unmount } = render(
+ ,
+ );
+ expect(container.querySelector("section")).toHaveClass(compactHero);
+ unmount();
+ }
+ });
+
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
withCapture(false);
render();
diff --git a/app/cases/_components/CaseHero.tsx b/app/cases/_components/CaseHero.tsx
index 3b56478..3a36c2e 100644
--- a/app/cases/_components/CaseHero.tsx
+++ b/app/cases/_components/CaseHero.tsx
@@ -1,8 +1,9 @@
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 { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
+import { compactHero } from "./hero-styles";
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
function CaseFacts({ facts }: { facts?: string[] }) {
@@ -28,7 +29,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
- sizes="(min-width: 1024px) 560px, 100vw"
+ sizes={coverSizes.hero}
priority
/>
@@ -37,7 +38,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
/**
* 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.
+ * no celular a mídia vem logo abaixo do título. Sem captura, o topo é uma coluna só e mais baixo.
*/
export function CaseHero({
caseStudy,
@@ -49,7 +50,11 @@ export function CaseHero({
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
return (
-
+
= {
* 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.
+ * `sizes` segue a grade de CaseMedia: a coluna da esquerda de um bloco de até 1040 px (88 vw da página abaixo de 1182 px)
+ * com a moldura do celular ao lado (220 px mais 32 px de vão); abaixo de 768 px, a largura da página menos 40 px.
*/
export function CasePlayer({
assets,
title,
device,
- sizes = "(min-width: 1280px) 900px, 100vw",
+ sizes = "(min-width: 1182px) 788px, (min-width: 768px) calc(88vw - 252px), calc(100vw - 40px)",
}: {
assets: ShowcaseAssets;
title: string;
diff --git a/app/cases/_components/CaseRelatedServices.tsx b/app/cases/_components/CaseRelatedServices.tsx
index 49bf97e..8701516 100644
--- a/app/cases/_components/CaseRelatedServices.tsx
+++ b/app/cases/_components/CaseRelatedServices.tsx
@@ -1,5 +1,5 @@
-import Link from "next/link";
import { textLinkDark } from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { getRelatedServices } from "@/lib/site-content";
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
@@ -19,12 +19,12 @@ export function CaseRelatedServices({ caseId }: { caseId: string }) {
{service.navLabel}
{service.intro}
-
Ver o serviço: {service.navLabel}
-
+
))}
diff --git a/app/cases/_components/PosterMosaic.test.tsx b/app/cases/_components/PosterMosaic.test.tsx
new file mode 100644
index 0000000..ed2c1f5
--- /dev/null
+++ b/app/cases/_components/PosterMosaic.test.tsx
@@ -0,0 +1,108 @@
+import { render } from "@testing-library/react";
+import { getShowcaseAssets } from "@/lib/project-showcase";
+import { coverSizes } from "./CaseCover";
+import { getCaseMedia } from "./case-media";
+import { getMosaicPosters, MOSAIC_SIZE, PosterMosaic } from "./PosterMosaic";
+
+/**
+ * Mosaico de pôsteres do topo de /servicos e /cases: quais pôsteres entram (só os com captura, no máximo três),
+ * e como cada imagem carrega (a primeira, a maior, na hora e com prioridade alta; as outras só quando aparecem).
+ */
+
+jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
+
+const mockedGetCaseMedia = jest.mocked(getCaseMedia);
+
+afterEach(() => {
+ mockedGetCaseMedia.mockReset();
+});
+
+describe("getMosaicPosters", () => {
+ it("path: every id has a capture -> keeps the order and stops at three", () => {
+ mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
+
+ const posters = getMosaicPosters(["a", "b", "c", "d"]);
+
+ expect(MOSAIC_SIZE).toBe(3);
+ expect(posters).toEqual([
+ { id: "a", poster: "/videos/a/desktop.webp" },
+ { id: "b", poster: "/videos/b/desktop.webp" },
+ { id: "c", poster: "/videos/c/desktop.webp" },
+ ]);
+ });
+
+ it("path: some ids have no capture -> skips them and fills with the next ones", () => {
+ mockedGetCaseMedia.mockImplementation((id, device) =>
+ id === "b" ? undefined : getShowcaseAssets(id, device),
+ );
+
+ expect(getMosaicPosters(["a", "b", "c", "d"]).map(({ id }) => id)).toEqual([
+ "a",
+ "c",
+ "d",
+ ]);
+ expect(mockedGetCaseMedia).toHaveBeenCalledWith("b", "desktop");
+ });
+
+ it("path: no id has a capture -> an empty list", () => {
+ mockedGetCaseMedia.mockReturnValue(undefined);
+
+ expect(getMosaicPosters(["a", "b"])).toEqual([]);
+ });
+});
+
+describe("PosterMosaic", () => {
+ const three = ["a", "b", "c"].map((id) => ({
+ id,
+ poster: `/videos/${id}/desktop.webp`,
+ }));
+
+ it("path: no posters -> renders nothing", () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => {
+ const { container } = render();
+
+ expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
+ const images = Array.from(container.querySelectorAll("img"));
+ expect(images).toHaveLength(3);
+ for (const image of images) {
+ expect(image).toHaveAttribute("alt", "");
+ }
+
+ const [main, ...side] = images;
+ expect(main).toHaveAttribute("loading", "eager");
+ expect(main).toHaveAttribute("fetchpriority", "high");
+ expect(main).toHaveAttribute("sizes", coverSizes.hero);
+ expect(main.closest("div")).toHaveClass("col-span-2");
+ for (const image of side) {
+ expect(image).toHaveAttribute("loading", "lazy");
+ expect(image).not.toHaveAttribute("fetchpriority");
+ // metade da coluna, e nunca a largura toda da tela
+ expect(image.getAttribute("sizes")).toBe(
+ "(min-width: 1440px) 274px, 20vw",
+ );
+ }
+ });
+
+ it("path: the smaller posters are hidden below 1024 px, so they are not downloaded on a phone", () => {
+ const { container } = render();
+
+ const [main, ...side] = Array.from(container.querySelectorAll("img")).map(
+ (image) => image.closest("div"),
+ );
+ expect(main).not.toHaveClass("hidden");
+ for (const frame of side) {
+ expect(frame).toHaveClass("hidden", "lg:block");
+ }
+ });
+
+ it("path: a single poster -> only the main one, with no side posters", () => {
+ const { container } = render();
+
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ });
+});
diff --git a/app/cases/_components/PosterMosaic.tsx b/app/cases/_components/PosterMosaic.tsx
new file mode 100644
index 0000000..563584b
--- /dev/null
+++ b/app/cases/_components/PosterMosaic.tsx
@@ -0,0 +1,68 @@
+import Image from "next/image";
+import { coverSizes, priorityImageProps } from "./CaseCover";
+import { getCaseMedia } from "./case-media";
+
+export interface MosaicPoster {
+ id: string;
+ poster: string;
+}
+
+/** Quantos pôsteres entram no mosaico: um grande em cima e dois menores embaixo. */
+export const MOSAIC_SIZE = 3;
+
+/**
+ * Pôsteres gravados dos cases, na ordem dada, só dos que têm captura no disco (no máximo `MOSAIC_SIZE`).
+ * Roda no servidor, como `getCaseMedia`.
+ */
+export function getMosaicPosters(ids: readonly string[]): MosaicPoster[] {
+ return ids
+ .flatMap((id) => {
+ const media = getCaseMedia(id, "desktop");
+ return media ? [{ id, poster: media.poster }] : [];
+ })
+ .slice(0, MOSAIC_SIZE);
+}
+
+const frame =
+ "relative aspect-[16/10] overflow-hidden border border-white/15 bg-[#0a0a0a]";
+
+/**
+ * Mosaico de pôsteres dos sites no topo das páginas internas, na coluna que ficaria vazia ao lado do título.
+ * É decorativo (texto alternativo vazio): quem lê os cases tem os links logo abaixo. O primeiro pôster é o maior
+ * e o que abre a página, então carrega na hora e com prioridade alta; no celular só ele aparece, e os outros dois
+ * (que não são baixados enquanto estão ocultos) entram a partir de 1024 px.
+ */
+export function PosterMosaic({
+ posters,
+}: {
+ posters: readonly MosaicPoster[];
+}) {
+ const [main, ...others] = posters;
+ if (!main) return null;
+
+ return (
+
+
+
+
+ {others.map(({ id, poster }) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/app/cases/_components/hero-styles.ts b/app/cases/_components/hero-styles.ts
new file mode 100644
index 0000000..ef1b900
--- /dev/null
+++ b/app/cases/_components/hero-styles.ts
@@ -0,0 +1,15 @@
+/**
+ * Faixa de topo de uma página interna que não tem imagem ao lado: uma coluna só e mais baixa que a padrão.
+ * O "!" vence o CSS sem camada de globals.css (.ds-section: 72 a 112 px de folga em cima e embaixo).
+ * Em cima fica a altura do cabeçalho fixo mais um respiro, para a trilha não ficar por baixo dele; embaixo, 48 px.
+ * No celular vale o padding de `pageTop` (button-styles.ts) e o padrão de baixo.
+ */
+export const compactHero =
+ "md:!pt-[calc(var(--site-header-height)+var(--sp-10))] md:!pb-sp-12";
+
+/**
+ * Topo com imagem: texto à esquerda e a mídia à direita a partir de 1024 px (1,1 parte para 1), uma coluna abaixo disso.
+ * Os `sizes` das imagens (CaseCover e PosterMosaic) seguem esta grade.
+ */
+export const heroWithMediaGrid =
+ "grid items-center gap-y-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16";
diff --git a/app/cases/page.loading.test.tsx b/app/cases/page.loading.test.tsx
index ed5cdbe..22edbf1 100644
--- a/app/cases/page.loading.test.tsx
+++ b/app/cases/page.loading.test.tsx
@@ -4,8 +4,8 @@ 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.
+ * Carregamento da lista de cases: o primeiro pôster do mosaico do topo (a maior imagem acima da dobra) carrega na hora
+ * e com prioridade alta, as capas dos cartões são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
*/
type LinkProps = ComponentProps;
@@ -40,14 +40,28 @@ function coverImages(container: HTMLElement) {
}
describe("CasesPage: carregamento das capas", () => {
- it("loads the first cover with priority and the others lazily", () => {
+ it("loads the first mosaic poster at once with high priority, and every other image lazily", () => {
const { container } = render();
- const [first, ...others] = coverImages(container);
- expect(first).not.toHaveAttribute("loading", "lazy");
- expect(others.length).toBeGreaterThan(0);
- for (const image of others) {
+ const mosaic = Array.from(
+ container.querySelectorAll("section [aria-hidden] img"),
+ ).filter((image) => image.getAttribute("alt") === "");
+ const [main, ...side] = mosaic;
+ expect(main).toHaveAttribute("loading", "eager");
+ expect(main).toHaveAttribute("fetchpriority", "high");
+ // a largura real do pôster, e não 100vw (que baixava 750 px para 330 px)
+ expect(main.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
+ expect(side.length).toBeGreaterThan(0);
+ for (const image of side) {
expect(image).toHaveAttribute("loading", "lazy");
+ expect(image).not.toHaveAttribute("fetchpriority");
+ }
+
+ const covers = coverImages(container);
+ expect(covers.length).toBeGreaterThan(0);
+ for (const image of covers) {
+ expect(image).toHaveAttribute("loading", "lazy");
+ expect(image).not.toHaveAttribute("fetchpriority");
}
});
diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx
index f034f2c..e59567f 100644
--- a/app/cases/page.paths.test.tsx
+++ b/app/cases/page.paths.test.tsx
@@ -63,3 +63,53 @@ describe("CasesPage: cases de cliente", () => {
);
});
});
+
+describe("CasesPage: topo com mosaico de pôsteres", () => {
+ it("path: captures found -> two columns with three posters of the other clients, not the first card's", () => {
+ const { container } = render();
+
+ const hero = container.querySelector("section");
+ expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
+ expect(hero?.className).not.toContain("md:!pb-sp-12");
+ const posters = Array.from(hero?.querySelectorAll("img") ?? []).map(
+ (image) => decodeURIComponent(image.getAttribute("src") ?? ""),
+ );
+ expect(posters).toHaveLength(3);
+ expect(posters.join(" ")).not.toContain(clientCases[0].id);
+ for (const [index, { id }] of clientCases.slice(1).entries()) {
+ expect(posters[index]).toContain(`/videos/${id}/desktop.webp`);
+ }
+ });
+
+ it("path: no capture for the other clients -> no mosaic, a lower one-column strip, and the first card cover takes the priority", () => {
+ mockedGetCaseMedia.mockImplementation((id, device) =>
+ id === clientCases[0].id ? actualGetCaseMedia(id, device) : undefined,
+ );
+
+ const { container } = render();
+
+ const hero = container.querySelector("section");
+ expect(hero?.querySelector("img")).toBeNull();
+ expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
+ expect(hero?.className).toContain("md:!pb-sp-12");
+ const firstCover = clientCard(container, clientCases[0].id).querySelector(
+ ".ds-bframe img",
+ );
+ expect(firstCover).toHaveAttribute("loading", "eager");
+ expect(firstCover).toHaveAttribute("fetchpriority", "high");
+ });
+});
+
+describe("CasesPage: prioridade da capa do primeiro cartão", () => {
+ it("path: mosaic at the top -> the first card cover stays lazy, the priority is the mosaic's first poster", () => {
+ const { container } = render();
+
+ const hero = container.querySelector("section");
+ expect(hero?.querySelector("img")).toHaveAttribute("loading", "eager");
+ for (const { id } of clientCases) {
+ const cover = clientCard(container, id).querySelector(".ds-bframe img");
+ expect(cover).toHaveAttribute("loading", "lazy");
+ expect(cover).not.toHaveAttribute("fetchpriority");
+ }
+ });
+});
diff --git a/app/cases/page.tsx b/app/cases/page.tsx
index b2377ef..dd54bc0 100644
--- a/app/cases/page.tsx
+++ b/app/cases/page.tsx
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
@@ -8,6 +7,7 @@ import {
secondaryButtonLight,
textLink,
} from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -17,7 +17,10 @@ import {
labCases,
siteUrl,
} from "@/lib/site-content";
+import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink";
+import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
+import { getMosaicPosters, PosterMosaic } from "./_components/PosterMosaic";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas";
@@ -36,40 +39,33 @@ export const metadata: Metadata = {
}),
};
-function CaseCard({
+/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
+function ClientCaseCard({
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. */
+ /** Capa que abre a página: só quando não há mosaico no topo, a capa passa a ser a maior imagem visível. */
priority?: boolean;
}) {
return (
- {withCover ? (
-
- ) : null}
+
{caseStudy.title}
{caseStudy.headline}
{caseStudy.summary}
-
- {ctaLabel}
-
+ Ver o case
+
{caseStudy.demoUrl ? (
+
diff --git a/app/components/Breadcrumb.prefetch.test.tsx b/app/components/Breadcrumb.prefetch.test.tsx
new file mode 100644
index 0000000..faff452
--- /dev/null
+++ b/app/components/Breadcrumb.prefetch.test.tsx
@@ -0,0 +1,52 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { Breadcrumb } from "./Breadcrumb";
+
+/**
+ * A trilha de navegação só pré-carrega a rota de um item quando a pessoa chega nele (mouse, teclado ou toque):
+ * o `prefetch` não aparece no HTML, então o Link é trocado por um que o expõe em `data-prefetch`.
+ */
+
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
+const items = [
+ { label: "Início", href: "/" },
+ { label: "Cases", href: "/cases" },
+];
+
+describe("Breadcrumb: pré-carregamento", () => {
+ it("path: opening the page -> no link pre-fetches", () => {
+ render();
+
+ for (const link of screen.getAllByRole("link")) {
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ }
+ });
+
+ it.each([
+ ["mouse", fireEvent.mouseEnter],
+ ["keyboard focus", fireEvent.focus],
+ ["touch", fireEvent.touchStart],
+ ])("path: %s reaching one link -> only that link pre-fetches", (_, event) => {
+ render();
+
+ const [home, cases] = screen.getAllByRole("link");
+ event(cases);
+
+ expect(cases).toHaveAttribute("data-prefetch", "null");
+ expect(home).toHaveAttribute("data-prefetch", "false");
+ });
+});
diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx
index 4c6f5e1..857f54d 100644
--- a/app/components/Breadcrumb.tsx
+++ b/app/components/Breadcrumb.tsx
@@ -1,6 +1,9 @@
-import Link from "next/link";
+import HoverPrefetchLink from "./HoverPrefetchLink";
-/** Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual. */
+/**
+ * Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual.
+ * Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink).
+ */
export function Breadcrumb({
items,
}: {
@@ -15,13 +18,13 @@ export function Breadcrumb({
className={index > 0 ? "flex items-center gap-sp-2" : undefined}
>
{index > 0 ? / : null}
-
{label}
-
+
))}
diff --git a/app/curriculo/page.test.tsx b/app/curriculo/page.test.tsx
index cd5df2d..0e08d09 100644
--- a/app/curriculo/page.test.tsx
+++ b/app/curriculo/page.test.tsx
@@ -78,3 +78,19 @@ it("offers the résumé PDF as a secondary action and measures the download", ()
fireEvent.click(pdf);
expect(trackCvDownload).toHaveBeenCalledWith({ placement: "curriculo" });
});
+
+it("opens with a lower one-column strip, the location line and spaced achievement lists", () => {
+ const { container } = render(CurriculoPage());
+
+ const hero = container.querySelector("section");
+ expect(hero?.className).toContain("md:!pb-sp-12");
+ expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
+ expect(hero).toHaveTextContent(
+ "Garuva e Joinville (SC) · atendimento remoto em todo o Brasil",
+ );
+ const achievements = container.querySelectorAll("article.ds-card ul.ds-list");
+ expect(achievements.length).toBeGreaterThan(0);
+ for (const list of achievements) {
+ expect(list).toHaveClass("space-y-sp-2");
+ }
+});
diff --git a/app/curriculo/page.tsx b/app/curriculo/page.tsx
index b8ff95b..5d97f07 100644
--- a/app/curriculo/page.tsx
+++ b/app/curriculo/page.tsx
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
-import Link from "next/link";
+import { compactHero } from "@/app/cases/_components/hero-styles";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { safeJsonLd } from "@/lib/jsonld";
import { portfolio } from "@/lib/portfolio-data";
import {
@@ -43,7 +44,9 @@ export default function CurriculoPage() {
return (
-
+
- Base em Garuva, atendimento a projetos no Norte de Santa Catarina e
- atuação remota. Este currículo reúne experiência profissional,
- formação e competências técnicas para avaliação de contratação.
+ Garuva e Joinville (SC) · atendimento remoto em todo o Brasil. Este
+ currículo reúne experiência profissional, formação e competências
+ técnicas para avaliação de contratação.
-
+
Falar sobre uma oportunidade
-
+
Baixar currículo em PDF
-
+
Procurando consultoria? Ver serviços
-
+
diff --git a/app/not-found.test.tsx b/app/not-found.test.tsx
index b723a72..3790a79 100644
--- a/app/not-found.test.tsx
+++ b/app/not-found.test.tsx
@@ -9,6 +9,23 @@ describe("not-found", () => {
expect(metadata.robots).not.toHaveProperty("follow");
});
+ it("has its own Open Graph and Twitter card, with no url (the home one does not apply to a page that does not exist)", () => {
+ expect(metadata.openGraph).toEqual({
+ locale: "pt_BR",
+ title: "Página não encontrada",
+ description: "A página que você procura não existe ou foi movida.",
+ siteName: "Evandro.dev.br",
+ type: "website",
+ });
+ expect(metadata.openGraph).not.toHaveProperty("url");
+ expect(metadata.twitter).toMatchObject({
+ card: "summary_large_image",
+ title: "Página não encontrada",
+ description: "A página que você procura não existe ou foi movida.",
+ });
+ expect(metadata.twitter).not.toHaveProperty("url");
+ });
+
it("keeps Googlebot out, since the root layout lets it index", () => {
expect(metadata.robots).toEqual({
googleBot: { index: false, follow: false },
@@ -30,4 +47,10 @@ describe("not-found", () => {
"/cases",
);
});
+
+ it("uses no lime: the lime is only for the contact action", () => {
+ const { container } = render();
+
+ expect(container.innerHTML).not.toMatch(/slash|ccff00/i);
+ });
});
diff --git a/app/not-found.tsx b/app/not-found.tsx
index d7b48a7..695b3dc 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,14 +1,34 @@
import { ArrowLeft, Code2, Home } from "lucide-react";
import type { Metadata } from "next";
-import Link from "next/link";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
+import { twitterHandle } from "@/lib/seo";
+import { siteName } from "@/lib/site-content";
+
+const title = "Página não encontrada";
+const description = "A página que você procura não existe ou foi movida.";
// O Next já escreve 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.
+// O cartão de compartilhamento é o do próprio 404 e fica sem `url`: o endereço pedido não existe, e o `og:url` da home
+// (herdado do layout, que é substituído por inteiro quando a página define `openGraph`) apontaria a prévia para outra página.
export const metadata: Metadata = {
- title: "Página não encontrada",
- description: "A página que você procura não existe ou foi movida.",
+ title,
+ description,
alternates: { canonical: null },
robots: { googleBot: { index: false, follow: false } },
+ openGraph: {
+ locale: "pt_BR",
+ title,
+ description,
+ siteName,
+ type: "website",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ creator: twitterHandle,
+ },
};
export default function NotFound() {
@@ -16,7 +36,7 @@ export default function NotFound() {
-
+
404
@@ -29,20 +49,20 @@ export default function NotFound() {
-
Voltar ao início
-
-
+
Ver os cases
-
+
diff --git a/app/privacidade/page.test.tsx b/app/privacidade/page.test.tsx
index 63534d7..9eb8ce7 100644
--- a/app/privacidade/page.test.tsx
+++ b/app/privacidade/page.test.tsx
@@ -64,6 +64,14 @@ it("uses the same shell as the home, with no narrow max-width", () => {
expect(metadata.title).toBe("Privacidade e consentimento");
});
+it("opens with the same lower one-column strip as the other inner pages", () => {
+ const { container } = render();
+
+ const hero = container.querySelector("section");
+ expect(hero?.className).toContain("md:!pb-sp-12");
+ expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
+});
+
it("keeps the lime fill for contact actions only", () => {
const { container } = render();
diff --git a/app/privacidade/page.tsx b/app/privacidade/page.tsx
index 8d61b5e..0e5b3fd 100644
--- a/app/privacidade/page.tsx
+++ b/app/privacidade/page.tsx
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
-import Link from "next/link";
+import { compactHero } from "@/app/cases/_components/hero-styles";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content";
@@ -37,7 +38,9 @@ export default function PrivacyPage() {
-
+
-
Privacidade e consentimento
Privacidade e consentimento
@@ -132,12 +134,12 @@ export default function PrivacyPage() {
Solicitações relacionadas aos seus direitos devem ser
encaminhadas ao e-mail publicado acima.
-
Voltar ao início
-
+
diff --git a/app/servicos/[slug]/page.test.tsx b/app/servicos/[slug]/page.test.tsx
index e7ce94e..8382bff 100644
--- a/app/servicos/[slug]/page.test.tsx
+++ b/app/servicos/[slug]/page.test.tsx
@@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react";
import { SEO_IDS } from "@/lib/seo";
import {
caseStudies,
+ clientCases,
getRelatedCases,
getServicePage,
isClientCase,
@@ -207,6 +208,33 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
]);
});
+it("fills the hub hero with a mosaic of three client posters and states the location in one line", () => {
+ const { container } = render();
+
+ const hero = container.querySelector("section");
+ expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
+ const posters = Array.from(hero?.querySelectorAll("img") ?? []);
+ expect(posters).toHaveLength(3);
+ for (const [index, poster] of posters.entries()) {
+ expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
+ `/videos/${clientCases[index].id}/desktop.webp`,
+ );
+ expect(poster).toHaveAttribute("alt", "");
+ }
+ // só o primeiro, o maior, carrega na hora e com prioridade alta
+ expect(posters[0]).toHaveAttribute("fetchpriority", "high");
+ expect(posters[1]).toHaveAttribute("loading", "lazy");
+ expect(hero).toHaveTextContent(
+ "Garuva e Joinville (SC) · atendimento remoto em todo o Brasil",
+ );
+ expect(container.textContent).not.toMatch(/perto de Garuva/);
+
+ // as capas dos projetos, mais abaixo, têm a largura da coluna e carregam preguiçosas
+ const cover = container.querySelector("#projetos .ds-bframe img");
+ expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
+ expect(cover).toHaveAttribute("loading", "lazy");
+});
+
it("keeps hub cards and structured items aligned", () => {
const { container } = render();
@@ -309,19 +337,75 @@ it("shows the client cases first, right after the hero, with the first cover in
expect(hero?.querySelector("a[href='/cases/sev-modaintima']")).not.toBeNull();
});
-it("hides the related block and the hero frame when a service has no case", async () => {
- const { container } = await renderService("tauri-rust");
+it("shows no case, no frame and no 'similar projects' button on the diagnosis service, only the Apolo note", async () => {
+ const { container } = await renderService("arquitetura-de-software");
+ // nenhum case de cliente é um diagnóstico: só a experiência na Apolo, em texto
expect(
- screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
- ).toBeNull();
+ screen.getByRole("heading", { name: "Onde já apliquei isso" }),
+ ).toBeVisible();
+ expect(screen.getByText(/Apolo Energia/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(
screen.queryByRole("link", { name: "Ver projetos parecidos" }),
).toBeNull();
+ expect(screen.queryByText(/SEV Moda Íntima/)).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
- // GCC Installer (Python e Tkinter) não pertence a um serviço de Tauri e Rust
- expect(container.textContent).not.toMatch(/GCC|Tkinter/);
+});
+
+it("lists the Windows app as a 'Projeto próprio' on the desktop service, saying it is made in Python", async () => {
+ const { container } = await renderService("tauri-rust");
+
+ expect(
+ screen.getByRole("link", { name: "Ler case: MinGW GCC Installer" }),
+ ).toHaveAttribute("href", "/cases/gcc-installer");
+ expect(
+ screen.getByText("MinGW GCC Installer · Projeto próprio"),
+ ).toBeVisible();
+ expect(screen.getByText(/em Python e não em Tauri/)).toBeVisible();
+ expect(
+ screen.getByRole("link", { name: "Ver projetos parecidos" }),
+ ).toHaveAttribute("href", "#casos-relacionados");
+ // sem captura gravada, não há moldura nem imagem
+ expect(container.querySelector(".ds-bframe")).toBeNull();
+});
+
+it("keeps the hero in one lower column when there is no media, and in two columns when there is", async () => {
+ for (const slug of [
+ "tauri-rust",
+ "integracao-hardware-software-iot",
+ "arquitetura-de-software",
+ ]) {
+ const { container, unmount } = await renderService(slug);
+ const hero = container.querySelector("section");
+ expect(hero?.className).toContain("md:!pb-sp-12");
+ expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
+ unmount();
+ }
+
+ const withMedia = await renderService("criacao-de-sites");
+ const hero = withMedia.container.querySelector("section");
+ expect(hero?.className).not.toContain("md:!pb-sp-12");
+ expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
+ const cover = hero?.querySelector(".ds-bframe img");
+ // a largura real da coluna da direita, e não 100vw; é a imagem que abre a página
+ expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
+ expect(cover).toHaveAttribute("loading", "eager");
+ expect(cover).toHaveAttribute("fetchpriority", "high");
+});
+
+it("puts the location in one line, 'Garuva e Joinville (SC)', and spaces the list items", async () => {
+ const { container } = await renderService("consultoria-nextjs");
+
+ expect(container.textContent).toMatch(
+ /Garuva e Joinville \(SC\) · atendimento remoto em todo o Brasil/,
+ );
+ expect(container.textContent).not.toMatch(/a partir de Garuva/);
+ const lists = Array.from(container.querySelectorAll("ul.ds-list"));
+ expect(lists).toHaveLength(4);
+ for (const list of lists) {
+ expect(list).toHaveClass("space-y-sp-2");
+ }
});
it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => {
diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx
index 7387e22..89f1756 100644
--- a/app/servicos/[slug]/page.tsx
+++ b/app/servicos/[slug]/page.tsx
@@ -1,13 +1,17 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { notFound } from "next/navigation";
import { getCaseMedia } from "@/app/cases/_components/case-media";
+import {
+ compactHero,
+ heroWithMediaGrid,
+} from "@/app/cases/_components/hero-styles";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import {
@@ -92,13 +96,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
+
) : null}
-
+
Fale diretamente com Evandro. Abre uma conversa, sem precisar
- preencher um formulário. Atendimento remoto a partir de Garuva,
- SC.
+ preencher um formulário. Garuva e Joinville (SC) · atendimento
+ remoto em todo o Brasil.
{step}
@@ -191,7 +195,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
Situações comuns em que este serviço ajuda. A sua será combinada
na conversa.
-
+
{service.examples.map((example) => (
{example}
))}
@@ -205,7 +209,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
Detalhes técnicos e tecnologias
Pode ser uma ideia, uma tarefa que toma tempo ou um sistema que
precisa melhorar. Não é preciso chegar com uma solução técnica.
- Atendo a partir de Garuva, no Norte de Santa Catarina, e
- remotamente.
+ Atendo em Garuva e Joinville (SC) e remotamente, em todo o Brasil.
{service.ctaLabel} no WhatsApp
-
+
Ver todos os serviços
-
+
diff --git a/app/servicos/_components/ServiceHeroCover.test.tsx b/app/servicos/_components/ServiceHeroCover.test.tsx
new file mode 100644
index 0000000..29106b8
--- /dev/null
+++ b/app/servicos/_components/ServiceHeroCover.test.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { coverSizes } from "@/app/cases/_components/CaseCover";
+import { getCaseMedia } from "@/app/cases/_components/case-media";
+import { getShowcaseAssets } from "@/lib/project-showcase";
+import { caseStudies } from "@/lib/site-content";
+import { ServiceHeroCover } from "./ServiceHeroCover";
+
+/**
+ * Capa de destaque do serviço: sem case não mostra nada; com case, é a imagem que abre a página (carga na hora,
+ * prioridade alta e a largura real da coluna) e o link só pré-carrega o case quando a pessoa chega nele.
+ */
+
+jest.mock("@/app/cases/_components/case-media", () => ({
+ getCaseMedia: jest.fn(),
+}));
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
+const mockedGetCaseMedia = jest.mocked(getCaseMedia);
+const sev = caseStudies.filter(({ id }) => id === "sev-modaintima")[0];
+
+afterEach(() => {
+ mockedGetCaseMedia.mockReset();
+});
+
+describe("ServiceHeroCover", () => {
+ it("path: no case -> renders nothing", () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("path: case with a capture -> a link to the case around a cover that loads first", () => {
+ mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
+ render();
+
+ const link = screen.getByRole("link", {
+ name: new RegExp(`^Ver o case ${sev.title}`),
+ });
+ expect(link).toHaveAttribute("href", `/cases/${sev.id}`);
+ const image = screen.getByAltText(`Captura de tela do site ${sev.title}`);
+ expect(image).toHaveAttribute("loading", "eager");
+ expect(image).toHaveAttribute("fetchpriority", "high");
+ expect(image).toHaveAttribute("sizes", coverSizes.hero);
+ });
+
+ it("path: link -> pre-fetches the case only after the pointer reaches it", () => {
+ mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
+ render();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link);
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ });
+});
diff --git a/app/servicos/_components/ServiceHeroCover.tsx b/app/servicos/_components/ServiceHeroCover.tsx
index a75ce67..6f82aee 100644
--- a/app/servicos/_components/ServiceHeroCover.tsx
+++ b/app/servicos/_components/ServiceHeroCover.tsx
@@ -1,5 +1,5 @@
-import Link from "next/link";
-import { CaseCover } from "@/app/cases/_components/CaseCover";
+import { CaseCover, coverSizes } from "@/app/cases/_components/CaseCover";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import type { CaseStudyContent } from "@/lib/site-content";
/** Capa do case de destaque ao lado do título do serviço. Sem case com captura, não mostra nada. */
@@ -11,15 +11,18 @@ export function ServiceHeroCover({
if (!caseStudy) return null;
return (
-
+ Ver o case {caseStudy.title}
-
+
);
}
diff --git a/app/servicos/_components/ServiceRelatedCases.tsx b/app/servicos/_components/ServiceRelatedCases.tsx
index 42739d2..7e51045 100644
--- a/app/servicos/_components/ServiceRelatedCases.tsx
+++ b/app/servicos/_components/ServiceRelatedCases.tsx
@@ -1,6 +1,6 @@
-import Link from "next/link";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
function ServiceCaseCard({
@@ -27,9 +27,12 @@ function ServiceCaseCard({
+ Apresente seu negócio, organize a operação ou conecte ferramentas
+ que hoje dão trabalho. Desenvolvo a solução a partir do que você
+ precisa resolver, não de uma lista de tecnologias.
+
@@ -230,7 +256,7 @@ export default function ServicesIndexPage() {
Antes de contratar
-
+
Quanto custa e quanto tempo leva?
@@ -243,7 +269,7 @@ export default function ServicesIndexPage() {
- Preciso estar perto de Garuva?
+ Preciso estar em Joinville ou em Garuva?
Não. O atendimento é remoto, com conversas, demonstrações e
@@ -280,12 +306,12 @@ export default function ServicesIndexPage() {
key={service.href}
className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-sp-8"
>
-
{service.label}
-
+
{service.description}
))}
@@ -305,7 +331,7 @@ export default function ServicesIndexPage() {
Conversar com Evandro no WhatsApp
diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts
new file mode 100644
index 0000000..c31d567
--- /dev/null
+++ b/app/sitemap.test.ts
@@ -0,0 +1,33 @@
+import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
+import sitemap from "./sitemap";
+
+/** O mapa do site lista a home, as páginas fixas, cada serviço e cada case, sem repetir endereço. */
+describe("sitemap", () => {
+ const urls = sitemap().map((entry) => entry.url);
+
+ it("lists the fixed pages, every service and every case, once each", () => {
+ expect(urls).toEqual(
+ expect.arrayContaining([
+ siteUrl,
+ `${siteUrl}/curriculo`,
+ `${siteUrl}/privacidade`,
+ `${siteUrl}/servicos`,
+ `${siteUrl}/cases`,
+ `${siteUrl}/ia`,
+ ...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`),
+ ...caseStudies.map(({ id }) => `${siteUrl}/cases/${id}`),
+ ]),
+ );
+ expect(new Set(urls).size).toBe(urls.length);
+ expect(urls).toHaveLength(6 + servicePages.length + caseStudies.length);
+ });
+
+ it("never lists the 404 page", () => {
+ expect(urls.some((url) => /not-found|nada/.test(url))).toBe(false);
+ });
+
+ it("gives every entry the same fixed date, so the sitemap is equal on every build", () => {
+ const dates = sitemap().map((entry) => String(entry.lastModified));
+ expect(new Set(dates).size).toBe(1);
+ });
+});
diff --git a/lib/seo.test.ts b/lib/seo.test.ts
index d1028dc..5ed0039 100644
--- a/lib/seo.test.ts
+++ b/lib/seo.test.ts
@@ -92,6 +92,7 @@ it("builds a canonical service with only the verified service area", () => {
provider: { "@id": SEO_IDS.person },
areaServed: [
{ "@type": "City", name: "Garuva" },
+ { "@type": "City", name: "Joinville" },
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
],
});
diff --git a/lib/seo.ts b/lib/seo.ts
index 3752278..9c3a37f 100644
--- a/lib/seo.ts
+++ b/lib/seo.ts
@@ -76,6 +76,7 @@ export function buildServiceJsonLd(service: ServicePageContent) {
provider: { "@id": SEO_IDS.person },
areaServed: [
{ "@type": "City", name: "Garuva" },
+ { "@type": "City", name: "Joinville" },
{
"@type": "AdministrativeArea",
name: "Norte de Santa Catarina",
diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts
index af2d0fe..a7b6938 100644
--- a/lib/site-content.test.ts
+++ b/lib/site-content.test.ts
@@ -100,16 +100,57 @@ it("maps each service to its client cases", () => {
"lavacao-047",
"sc-plus",
]);
- expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([
- "sev-modaintima",
- ]);
- // GCC Installer é Python/Tkinter: não combina com um serviço de Tauri e Rust
- expect(getService("tauri-rust").relatedCaseIds).toEqual([]);
+ // nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo
+ expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]);
+ expect(getService("arquitetura-de-software").caseRelevance).toEqual({});
+ // o GCC Installer é um app para Windows (em Python): entra no serviço de desktop como projeto próprio
+ expect(getService("tauri-rust").relatedCaseIds).toEqual(["gcc-installer"]);
expect(getService("integracao-hardware-software-iot").relatedCaseIds).toEqual(
["twins-lab"],
);
});
+it("says what the Next.js service cases really prove: sites built by me, not a diagnosis", () => {
+ const { caseRelevance } = getService("consultoria-nextjs");
+ for (const id of ["sev-modaintima", "lavacao-047"]) {
+ expect(caseRelevance[id]).toMatch(/construí em Next\.js 16/);
+ expect(caseRelevance[id]).not.toMatch(/diagn|conheço por dentro/i);
+ }
+});
+
+it("is honest that the desktop case is a Windows app made in Python, not in Tauri", () => {
+ expect(getService("tauri-rust").caseRelevance["gcc-installer"]).toMatch(
+ /Windows.*Python/,
+ );
+});
+
+it("places the services in Joinville and SC instead of only 'em Garuva'", () => {
+ expect(getService("criacao-de-sites").metaTitle).toBe(
+ "Criação de sites em Joinville e SC",
+ );
+ for (const service of servicePages) {
+ expect(service.metaTitle).not.toMatch(/em Garuva/);
+ expect(service.metaDescription).toMatch(/Garuva e Joinville \(SC\)/);
+ }
+});
+
+it("opens the case lists with what sells: the catalog, the order by WhatsApp and the panel", () => {
+ const deliveries =
+ caseStudies.find(({ id }) => id === "sev-modaintima")?.deliveries ?? [];
+ const [catalog, whatsapp, panel] = deliveries;
+ expect(catalog).toMatch(/^Catálogo por tamanho/);
+ expect(whatsapp).toMatch(/^Pedido pelo WhatsApp/);
+ expect(panel).toMatch(/^Painel para a loja cadastrar as peças/);
+ // a vitrine com animações, que era a primeira, vai para o fim
+ expect(deliveries[deliveries.length - 1]).toMatch(
+ /^Uma vitrine com animações/,
+ );
+ // a animação da Lavação também não abre a lista
+ expect(
+ caseStudies.find(({ id }) => id === "lavacao-047")?.deliveries[0],
+ ).toMatch(/^Tabela de preços/);
+});
+
it("puts client cases before laboratory cases when listing related work", () => {
expect(
getRelatedCases(["twins-lab", "lavacao-047", "sev-modaintima"]).map(
@@ -128,14 +169,11 @@ it("derives the exact related service set from the existing relationship", () =>
.map(({ slug }) => slug)
.sort(),
).toEqual(
- [
- "criacao-de-sites",
- "engenharia-de-software",
- "consultoria-nextjs",
- "arquitetura-de-software",
- ].sort(),
+ ["criacao-de-sites", "engenharia-de-software", "consultoria-nextjs"].sort(),
);
- expect(getRelatedServices("gcc-installer")).toEqual([]);
+ expect(getRelatedServices("gcc-installer").map(({ slug }) => slug)).toEqual([
+ "tauri-rust",
+ ]);
});
it("resolves every declared related case ID", () => {
diff --git a/lib/site-content.ts b/lib/site-content.ts
index 60d9873..af92fb5 100644
--- a/lib/site-content.ts
+++ b/lib/site-content.ts
@@ -8,7 +8,7 @@ export interface ServicePageContent {
slug: string;
navLabel: string;
eyebrow: string;
- /** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
+ /** Título para busca, separado do h1: até 43 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */
metaDescription: string;
@@ -41,7 +41,7 @@ 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. */
+ /** Título para busca, separado do h1: até 43 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */
metaDescription: string;
@@ -74,9 +74,9 @@ export const servicePages: ServicePageContent[] = [
slug: "criacao-de-sites",
navLabel: "Sites profissionais",
eyebrow: "Presença digital",
- metaTitle: "Criação de sites para empresas em Garuva",
+ metaTitle: "Criação de sites em Joinville e SC",
metaDescription:
- "Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
+ "Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
headline: "Um site que explica seu negócio.",
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.",
@@ -145,7 +145,7 @@ export const servicePages: ServicePageContent[] = [
eyebrow: "Engenharia de software",
metaTitle: "Sistemas web e aplicativos sob medida",
metaDescription:
- "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.",
+ "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.",
headline: "Sistemas e apps para o seu trabalho.",
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.",
@@ -210,7 +210,7 @@ export const servicePages: ServicePageContent[] = [
eyebrow: "Consultoria Next.js",
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. Garuva-SC, 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. Garuva e Joinville (SC), remoto em todo o 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.",
@@ -247,9 +247,9 @@ export const servicePages: ServicePageContent[] = [
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.",
+ "Uma loja que construí em Next.js 16, com catálogo, busca, estoque e painel.",
"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.",
+ "Um site que construí em Next.js 16, em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
"sc-plus":
"Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.",
},
@@ -277,7 +277,7 @@ export const servicePages: ServicePageContent[] = [
eyebrow: "Aplicativos desktop",
metaTitle: "Aplicativos desktop para Windows e Linux",
metaDescription:
- "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.",
+ "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Garuva e Joinville (SC), atendimento remoto em todo o 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.",
@@ -316,8 +316,11 @@ export const servicePages: ServicePageContent[] = [
"Integração com o sistema",
],
ctaLabel: "Conversar sobre meu aplicativo desktop",
- relatedCaseIds: [],
- caseRelevance: {},
+ relatedCaseIds: ["gcc-installer"],
+ caseRelevance: {
+ "gcc-installer":
+ "Um aplicativo para Windows feito por mim, em Python e não em Tauri: lista as versões do compilador, instala e ajusta o PATH em uma única janela.",
+ },
faq: [
{
question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
@@ -342,7 +345,7 @@ export const servicePages: ServicePageContent[] = [
eyebrow: "Hardware, software e IoT",
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. Garuva-SC, Joinville e remoto no Brasil.",
+ "Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva e Joinville (SC), remoto em todo o 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.",
@@ -407,7 +410,7 @@ export const servicePages: ServicePageContent[] = [
eyebrow: "Diagnóstico e decisão",
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 Garuva-SC para todo o Brasil.",
+ "Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Garuva e Joinville (SC), remoto em 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.",
@@ -447,11 +450,8 @@ export const servicePages: ServicePageContent[] = [
"Sistemas críticos",
],
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.",
- },
+ relatedCaseIds: [],
+ caseRelevance: {},
experienceNote:
"Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
faq: [
@@ -492,12 +492,12 @@ export const caseStudies: CaseStudyContent[] = [
problem:
"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.",
+ "Catálogo por tamanho, com filtros e busca: a cliente encontra a peça, escolhe a medida e vê primeiro os itens mais relevantes.",
"Pedido pelo WhatsApp: a cliente escolhe os produtos no site e envia o pedido para a loja.",
- "Frete calculado pela distância até o endereço da cliente.",
- "Painel administrativo para a loja cadastrar produtos, acompanhar pedidos e controlar o estoque.",
+ "Painel para a loja cadastrar as peças, acompanhar os pedidos e controlar o estoque.",
"Estoque controlado por tamanho, para cada medida ter a sua quantidade.",
+ "Frete calculado pela distância até o endereço da cliente.",
+ "Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
],
stack: [
"Next.js 16",
@@ -538,8 +538,8 @@ export const caseStudies: CaseStudyContent[] = [
"Painel do dono: ele altera preços, textos, fotos e o mapa sozinho, e a mudança aparece no site sem nova publicação.",
"Galeria de trabalhos por serviço, para o cliente ver o resultado antes de chamar.",
"Mapa de localização, para o cliente achar a lavação.",
- "Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.",
"Informações organizadas para o Google e para assistentes de IA entenderem o que a lavação faz e onde fica.",
+ "Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.",
],
stack: [
"Next.js 16",
@@ -667,8 +667,8 @@ export const caseStudies: CaseStudyContent[] = [
"A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.",
deliveries: [
"Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.",
- "Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
"Contato direto na própria página.",
+ "Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
"Título, descrição e imagem de compartilhamento próprios, para o link aparecer bem nos buscadores e nas redes.",
"Dados estruturados que ajudam o Google a entender de quem é a página.",
],
@@ -726,8 +726,8 @@ export const caseStudies: CaseStudyContent[] = [
problem:
"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.",
+ "Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.",
"A qualidade da cena se ajusta ao aparelho: celulares, conexões mais simples e quem prefere menos movimento recebem uma versão mais leve.",
"Um painel para controle de projetos, desenvolvido junto com a primeira página do site.",
],