feat(cases): prova certa por serviço, páginas internas com trabalho no topo

Diagnóstico sem case que não cabe; desktop com o GCC Installer como projeto próprio; IoT com o Twins Lab e a nota da Apolo. Mosaico de pôsteres em /servicos e /cases, faixa baixa em uma coluna no currículo, privacidade e laboratório; Laboratório como lista compacta; SEV começando por catálogo, pedido no WhatsApp e painel; Joinville e SC nos títulos; imagens do LCP com fetchPriority e sizes reais; pré-carregamento só no hover; 404 com Open Graph próprio.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:35 -03:00
1 parent 7b633f0f4a
commit de6af74a0c
37 files changed
+1062 -254

No files matched your search

+32 -12
View File
@@ -51,7 +51,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
/**
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case.
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido".
* 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,
@@ -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",
+9 -8
View File
@@ -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({
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={copy.subject(caseStudy.title)}
service={similarProjectSubject(caseStudy.title)}
placement="case_detail"
className={primaryButton}
>
{copy.action}
</WhatsAppLink>
<Link href="/cases" className={secondaryButtonDark}>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
</Link>
</HoverPrefetchLink>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
+15 -5
View File
@@ -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(<CaseCover {...props} />);
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(<CaseCover {...props} sizes="320px" priority />);
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\)$/);
}
});
});
+32 -3
View File
@@ -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 `<link rel="preload">`; 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 (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
@@ -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"
/>
</div>
@@ -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(<CaseCoverLink {...props} />);
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(<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",
);
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(
<CaseCoverLink {...props} sizes="320px" priority prefetch={false} />,
);
render(<CaseCoverLink {...props} sizes="320px" priority />);
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", () => {
+6 -8
View File
@@ -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 (
<Link
<HoverPrefetchLink
href={`/cases/${id}`}
aria-hidden="true"
tabIndex={-1}
prefetch={prefetch}
className="mb-sp-5 block"
>
<CaseCover
@@ -38,6 +36,6 @@ export function CaseCoverLink({
sizes={sizes}
priority={priority}
/>
</Link>
</HoverPrefetchLink>
);
}
@@ -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(<CaseHero caseStudy={client} isClient />);
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(
<CaseHero caseStudy={caseStudy} isClient={isClient} />,
);
expect(container.querySelector("section")).toHaveClass(compactHero);
unmount();
}
});
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
withCapture(false);
render(<CaseHero caseStudy={client} isClient />);
+9 -4
View File
@@ -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
/>
</div>
@@ -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 (
<section className={`ds-section ds-section-dark ${pageTop}`}>
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMedia ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${
hasMedia
+3 -1
View File
@@ -14,12 +14,14 @@ const deviceLabel: Record<Device, string> = {
* 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;
@@ -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 }) {
<article className="ds-card w-full">
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
<p className="ds-copy">{service.intro}</p>
<Link
<HoverPrefetchLink
className={`${textLinkDark} mt-sp-4 self-start`}
href={`/servicos/${service.slug}`}
>
Ver o serviço: {service.navLabel}
</Link>
</HoverPrefetchLink>
</article>
</li>
))}
+108
View File
@@ -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(<PosterMosaic posters={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => {
const { container } = render(<PosterMosaic posters={three} />);
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(<PosterMosaic posters={three} />);
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(<PosterMosaic posters={three.slice(0, 1)} />);
expect(container.querySelectorAll("img")).toHaveLength(1);
});
});
+68
View File
@@ -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 (
<div className="grid min-w-0 grid-cols-2 gap-sp-3" aria-hidden="true">
<div className={`${frame} col-span-2`}>
<Image
src={main.poster}
alt=""
fill
sizes={coverSizes.hero}
{...priorityImageProps}
className="object-cover object-top"
/>
</div>
{others.map(({ id, poster }) => (
<div key={id} className={`${frame} hidden lg:block`}>
<Image
src={poster}
alt=""
fill
sizes="(min-width: 1440px) 274px, 20vw"
className="object-cover object-top"
/>
</div>
))}
</div>
);
}
+15
View File
@@ -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";
+21 -7
View File
@@ -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<typeof import("next/link").default>;
@@ -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(<CasesPage />);
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<HTMLImageElement>("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");
}
});
+50
View File
@@ -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(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(hero?.className).not.toContain("md:!pb-sp-12");
const posters = Array.from(hero?.querySelectorAll("img") ?? []).map(
(image) => decodeURIComponent(image.getAttribute("src") ?? ""),
);
expect(posters).toHaveLength(3);
expect(posters.join(" ")).not.toContain(clientCases[0].id);
for (const [index, { id }] of clientCases.slice(1).entries()) {
expect(posters[index]).toContain(`/videos/${id}/desktop.webp`);
}
});
it("path: no capture for the other clients -> no mosaic, a lower one-column strip, and the first card cover takes the priority", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === clientCases[0].id ? actualGetCaseMedia(id, device) : undefined,
);
const { container } = render(<CasesPage />);
const 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(<CasesPage />);
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");
}
});
});
+58 -37
View File
@@ -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 (
<article className="ds-card !h-auto">
{withCover ? (
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
prefetch={false}
/>
) : null}
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
/>
<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
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
prefetch={false}
className={secondaryButtonLight}
>
{ctaLabel}
</Link>
Ver o case
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
@@ -85,7 +81,32 @@ function CaseCard({
);
}
/**
* Uma linha por projeto do laboratório: nome, o que é, a marca "Projeto próprio" e o link. Sem capa nem resumo, para a lista
* ocupar pouco espaço (no celular, o nome e a marca ficam na primeira linha).
*/
function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-sp-6 gap-y-sp-1 py-sp-4 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto_auto]">
<h3 className="text-base font-semibold leading-6">{caseStudy.title}</h3>
<p className="ds-kicker justify-self-end md:order-3">Projeto próprio</p>
<p className="col-span-2 text-[15px] leading-6 text-[var(--ink-80)] md:order-2 md:col-span-1">
{caseStudy.subtitle}
</p>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`}
>
Ver o projeto<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
</li>
);
}
export default function CasesPage() {
// o mosaico mostra os outros clientes: o primeiro já aparece, com a capa, no primeiro cartão logo abaixo
const posters = getMosaicPosters(clientCases.slice(1).map(({ id }) => id));
const hasMosaic = posters.length > 0;
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -116,8 +137,14 @@ export default function CasesPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMosaic ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${hasMosaic ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<Breadcrumb
items={[
@@ -133,6 +160,7 @@ export default function CasesPage() {
{pageDescription}
</p>
</div>
<PosterMosaic posters={posters} />
</div>
</section>
@@ -143,12 +171,10 @@ export default function CasesPage() {
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy, index) => (
<CaseCard
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover
ctaLabel="Ver o case"
priority={index === 0}
priority={index === 0 && !hasMosaic}
/>
))}
</div>
@@ -163,16 +189,11 @@ export default function CasesPage() {
<p className="ds-copy max-w-[64ch]">
Projetos pessoais e de estudo, feitos para praticar e testar ideias.
</p>
<div className="ds-grid-2 mt-sp-8 items-start">
<ul className="mt-sp-6 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{labCases.map((caseStudy) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover={false}
ctaLabel="Ver o projeto"
/>
<LabCaseRow key={caseStudy.id} caseStudy={caseStudy} />
))}
</div>
</ul>
</div>
</section>
@@ -187,15 +208,15 @@ export default function CasesPage() {
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service="um projeto parecido com os cases do portfólio"
service={CASES_SUBJECT}
placement="cases_hub_footer"
className={primaryButton}
>
Conversar sobre meu projeto
</WhatsAppLink>
<Link href="/servicos" className={secondaryButtonDark}>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
Ver os serviços
</Link>
</HoverPrefetchLink>
</div>
</div>
</section>
@@ -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
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const items = [
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
];
describe("Breadcrumb: pré-carregamento", () => {
it("path: opening the page -> no link pre-fetches", () => {
render(<Breadcrumb items={items} />);
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(<Breadcrumb items={items} />);
const [home, cases] = screen.getAllByRole("link");
event(cases);
expect(cases).toHaveAttribute("data-prefetch", "null");
expect(home).toHaveAttribute("data-prefetch", "false");
});
});
+7 -4
View File
@@ -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 ? <span aria-hidden="true">/</span> : null}
<Link
<HoverPrefetchLink
aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text"
href={href}
>
{label}
</Link>
</HoverPrefetchLink>
</li>
))}
</ol>
+16
View File
@@ -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");
}
});
+21 -12
View File
@@ -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 (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className="ds-section-shell">
<Breadcrumb
items={[
@@ -56,20 +59,26 @@ export default function CurriculoPage() {
Engenheiro da Computação e Desenvolvedor de Software.
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
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.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link className="ds-link ds-link-accent" href="/#contact">
<HoverPrefetchLink
className="ds-link ds-link-accent"
href="/#contact"
>
Falar sobre uma oportunidade
</Link>
</HoverPrefetchLink>
<CvDownloadLink className="ds-link ds-link-muted">
Baixar currículo em PDF
</CvDownloadLink>
<Link className="ds-link ds-link-text" href="/servicos">
<HoverPrefetchLink
className="ds-link ds-link-text"
href="/servicos"
>
Procurando consultoria? Ver serviços
</Link>
</HoverPrefetchLink>
</div>
</div>
</section>
@@ -87,7 +96,7 @@ export default function CurriculoPage() {
{experience.title} — {experience.company}
</h3>
<p className="ds-copy">{experience.description}</p>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
<ul className="ds-list mt-sp-5 space-y-sp-2 pl-[1.15rem]">
{experience.achievements.map((achievement) => (
<li key={achievement}>{achievement}</li>
))}
@@ -123,9 +132,9 @@ export default function CurriculoPage() {
))}
</div>
<div className="mt-sp-8">
<Link className="ds-link ds-link-muted" href="/cases">
<HoverPrefetchLink className="ds-link ds-link-muted" href="/cases">
Ver projetos e cases
</Link>
</HoverPrefetchLink>
</div>
</div>
</section>
+23
View File
@@ -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(<NotFound />);
expect(container.innerHTML).not.toMatch(/slash|ccff00/i);
});
});
+28 -8
View File
@@ -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 <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.
// 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() {
<main className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-8">
<Code2 className="w-24 h-24 text-[var(--slash)] mx-auto mb-4 opacity-60" />
<Code2 className="w-24 h-24 text-slate-500 mx-auto mb-4 opacity-60" />
<h1 className="font-[family-name:var(--font-display)] text-6xl md:text-8xl font-extrabold text-white mb-4">
404
</h1>
@@ -29,20 +49,20 @@ export default function NotFound() {
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
<HoverPrefetchLink
href="/"
className="px-6 py-3 border border-slate-600 hover:border-slate-400 bg-transparent text-white rounded-lg font-[family-name:var(--font-body)] text-base font-semibold transition-all flex items-center justify-center gap-2"
>
<Home className="w-5 h-5" />
Voltar ao início
</Link>
<Link
</HoverPrefetchLink>
<HoverPrefetchLink
href="/cases"
className="px-6 py-3 font-[family-name:var(--font-body)] text-base text-slate-300 hover:text-white underline underline-offset-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
Ver os cases
</Link>
</HoverPrefetchLink>
</div>
</div>
</main>
+8
View File
@@ -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(<PrivacyPage />);
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(<PrivacyPage />);
+7 -5
View File
@@ -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() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className="ds-section-shell">
<Breadcrumb
items={[
@@ -45,7 +48,6 @@ export default function PrivacyPage() {
{ label: "Privacidade", href: "/privacidade" },
]}
/>
<p className="ds-eyebrow">Privacidade e consentimento</p>
<h1 className="ds-section-title text-white">
Privacidade e consentimento
</h1>
@@ -132,12 +134,12 @@ export default function PrivacyPage() {
Solicitações relacionadas aos seus direitos devem ser
encaminhadas ao e-mail publicado acima.
</p>
<Link
<HoverPrefetchLink
className="ds-link ds-link-muted mt-sp-5 self-start"
href="/"
>
Voltar ao início
</Link>
</HoverPrefetchLink>
</article>
</div>
+90 -6
View File
@@ -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(<ServicesIndexPage />);
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(<ServicesIndexPage />);
@@ -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 () => {
+25 -19
View File
@@ -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) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<section
className={`ds-section ds-section-dark ${pageTop} ${
heroCase ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${
heroCase
? "grid items-center gap-sp-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-sp-16"
: ""
}`}
className={`ds-section-shell ${heroCase ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<Breadcrumb
@@ -129,10 +133,10 @@ export default async function ServicePage({ params }: ServicePageProps) {
</a>
) : null}
</div>
<p className="mt-sp-4 text-[15px] md:text-sm leading-6 text-white/70">
<p className="mt-sp-4 text-[15px] leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
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.
</p>
</div>
<ServiceHeroCover caseStudy={heroCase} />
@@ -150,7 +154,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-grid-2">
<article className="ds-card">
<h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
<ul className="ds-list">
<ul className="ds-list space-y-sp-2">
{service.audience.map((item) => (
<li key={item}>{item}</li>
))}
@@ -159,7 +163,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<article className="ds-card">
<h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
<ul className="ds-list">
<ul className="ds-list space-y-sp-2">
{service.outcomes.map((item) => (
<li key={item}>{item}</li>
))}
@@ -175,7 +179,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-[15px] md:text-sm leading-8 text-[var(--ink-80)]">
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-[15px] leading-8 text-[var(--ink-80)]">
{service.process.map((step) => (
<li key={step} className="pl-sp-2">
{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.
</p>
<ul className="ds-list">
<ul className="ds-list space-y-sp-2">
{service.examples.map((example) => (
<li key={example}>{example}</li>
))}
@@ -205,7 +209,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
Detalhes técnicos e tecnologias
</summary>
<div className="max-w-[80ch]">
<ul className="ds-list">
<ul className="ds-list space-y-sp-2">
{service.technicalDetails.map((detail) => (
<li key={detail}>{detail}</li>
))}
@@ -240,8 +244,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<p className="ds-copy !text-[rgba(255,255,255,0.76)]">
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.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<WhatsAppLink
@@ -251,9 +254,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<Link href="/servicos" className={secondaryButtonDark}>
<HoverPrefetchLink
href="/servicos"
className={secondaryButtonDark}
>
Ver todos os serviços
</Link>
</HoverPrefetchLink>
</div>
</div>
</div>
@@ -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
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
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(<ServiceHeroCover />);
expect(container).toBeEmptyDOMElement();
});
it("path: case with a capture -> a link to the case around a cover that loads first", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<ServiceHeroCover caseStudy={sev} />);
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(<ServiceHeroCover caseStudy={sev} />);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
});
@@ -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 (
<Link href={`/cases/${caseStudy.id}`} className="block min-w-0">
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className="block min-w-0"
>
<span className="sr-only">Ver o case {caseStudy.title}</span>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 520px, 100vw"
sizes={coverSizes.hero}
priority
/>
</Link>
</HoverPrefetchLink>
);
}
@@ -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({
<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}>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ler case: {caseStudy.title}
</Link>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
+41
View File
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { compactHero } from "@/app/cases/_components/hero-styles";
import ServicesIndexPage from "./page";
/**
* Teste de caminhos-base do topo de ServicesIndexPage sem mosaico: nenhum case de cliente tem captura gravada.
* O caminho com o mosaico (três pôsteres, duas colunas) está em [slug]/page.test.tsx.
*/
jest.mock("@/app/cases/_components/case-media", () => ({
getCaseMedia: jest.fn(),
}));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("ServicesIndexPage: topo sem captura gravada", () => {
it("path: no capture for any client -> no mosaic, a lower one-column strip, the text and buttons stay", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<ServicesIndexPage />);
const hero = container.querySelector("section");
expect(hero).toHaveClass(compactHero);
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(hero?.querySelector("img")).toBeNull();
expect(
screen.getByRole("heading", {
level: 1,
name: "Sites, sistemas e aplicativos sob medida.",
}),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Ver projetos realizados" }),
).toHaveAttribute("href", "#projetos");
});
});
+71 -45
View File
@@ -1,6 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
import { coverSizes } from "@/app/cases/_components/CaseCover";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import {
compactHero,
heroWithMediaGrid,
} from "@/app/cases/_components/hero-styles";
import {
getMosaicPosters,
PosterMosaic,
} from "@/app/cases/_components/PosterMosaic";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
@@ -8,15 +16,17 @@ import {
secondaryButtonDark,
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";
import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content";
import { SERVICES_SUBJECT } from "@/lib/whatsapp";
const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Sites, sistemas e aplicativos sob medida";
const pageDescription =
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Atendimento de Garuva-SC para todo o Brasil.";
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Garuva e Joinville (SC), remoto em todo o Brasil.";
const businessIntents = [
{
@@ -60,6 +70,9 @@ export const metadata: Metadata = {
};
export default function ServicesIndexPage() {
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia
const posters = getMosaicPosters(clientCases.map(({ id }) => id));
const hasMosaic = posters.length > 0;
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -90,41 +103,53 @@ export default function ServicesIndexPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
]}
/>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Sites, sistemas e aplicativos sob medida.
</h1>
<p className="ds-copy max-w-[62ch]">
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.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
<WhatsAppLink
className={primaryButton}
service="Sites, sistemas e aplicativos"
placement="service_hub_hero"
>
Falar com Evandro no WhatsApp
</WhatsAppLink>
<a
href="#projetos"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver projetos realizados
</a>
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMosaic ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${hasMosaic ? heroWithMediaGrid : ""}`}
>
<div>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
]}
/>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Sites, sistemas e aplicativos sob medida.
</h1>
<p className="ds-copy max-w-[62ch]">
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.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
<WhatsAppLink
className={primaryButton}
service={SERVICES_SUBJECT}
placement="service_hub_hero"
>
Falar com Evandro no WhatsApp
</WhatsAppLink>
<a
href="#projetos"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver projetos realizados
</a>
</div>
<p className="ds-copy max-w-[62ch]">
Sou Evandro, desenvolvedor de software, e a conversa é direta
comigo.
</p>
<p className="mt-sp-2 text-[15px] leading-6 text-white/70">
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil
</p>
</div>
<p className="ds-copy max-w-[62ch]">
Sou Evandro, desenvolvedor em Garuva, Santa Catarina. Atendimento
remoto para todo o Brasil, com conversa direta comigo.
</p>
<PosterMosaic posters={posters} />
</div>
</section>
@@ -155,12 +180,12 @@ export default function ServicesIndexPage() {
>
{intent.ctaLabel}
</WhatsAppLink>
<Link
<HoverPrefetchLink
href={intent.href}
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{intent.linkLabel}
</Link>
</HoverPrefetchLink>
</div>
</article>
))}
@@ -191,16 +216,17 @@ export default function ServicesIndexPage() {
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.column}
/>
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<Link
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} mt-sp-4 focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver o case {caseStudy.title}
</Link>
</HoverPrefetchLink>
</article>
))}
</div>
@@ -230,7 +256,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-[15px] md:text-sm">
<dl className="mt-sp-5 space-y-sp-6 text-[15px]">
<div>
<dt className="font-semibold">
Quanto custa e quanto tempo leva?
@@ -243,7 +269,7 @@ export default function ServicesIndexPage() {
</div>
<div>
<dt className="font-semibold">
Preciso estar perto de Garuva?
Preciso estar em Joinville ou em Garuva?
</dt>
<dd className="ds-copy !mt-sp-2">
Não. O atendimento é remoto, com conversas, demonstrações e
@@ -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"
>
<Link
<HoverPrefetchLink
href={service.href}
className="inline-flex min-h-11 items-center font-semibold underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
>
{service.label}
</Link>
</HoverPrefetchLink>
<p className="ds-copy !mt-0">{service.description}</p>
</li>
))}
@@ -305,7 +331,7 @@ export default function ServicesIndexPage() {
</p>
<WhatsAppLink
className={`${primaryButton} mt-sp-6`}
service="Sites, sistemas e aplicativos"
service={SERVICES_SUBJECT}
placement="service_hub_footer"
>
Conversar com Evandro no WhatsApp
+33
View File
@@ -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);
});
});
+1
View File
@@ -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" },
],
});
+1
View File
@@ -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",
+50 -12
View File
@@ -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", () => {
+25 -25
View File
@@ -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.",
],