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:
1 parent
7b633f0f4a
commit
de6af74a0c
37 files changed
+1062
-254
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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\)$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user