feat(cases): cases de clientes, serviços ligados à prova e SEO por página
Cases da SEV Moda Íntima e da Lavação 047; case no formato cliente, problema, entrega, mídia e contato, com detalhes técnicos recolhidos; sem GitHub, ressalvas e fontes de commit. /cases separa clientes e Laboratório. Serviços mostram o case de cliente certo e o bloco de investimento e prazo só aparece preenchido. Metadata social própria por página, not-found com noindex, sitemap com /ia.
This commit is contained in:
1 parent
3756905a1b
commit
6e1022b58c
44 files changed
+2691
-1424
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { CaseCover } 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`.
|
||||
*/
|
||||
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
const props = {
|
||||
id: "sev-modaintima",
|
||||
title: "SEV Moda Íntima",
|
||||
demoUrl: "https://sevmodaintima.com.br",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("CaseCover", () => {
|
||||
it("path: no desktop capture -> renders nothing", () => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
const { container } = render(<CaseCover {...props} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
|
||||
});
|
||||
|
||||
it("path: capture found -> poster inside the browser frame, with the real domain in the bar", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
const { container } = render(<CaseCover {...props} />);
|
||||
|
||||
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||
expect(container.querySelector(".ds-bframe")).toContainElement(image);
|
||||
expect(image.getAttribute("src")).toContain(
|
||||
encodeURIComponent(`/videos/${props.id}/desktop.webp`),
|
||||
);
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: sizes and priority omitted -> default sizes and lazy loading", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
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("loading", "lazy");
|
||||
});
|
||||
|
||||
it("path: sizes and priority given -> uses them", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import Image from "next/image";
|
||||
import {
|
||||
BrowserFrame,
|
||||
displayUrl,
|
||||
} from "@/app/components/showcase/BrowserFrame";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
import "../../components/showcase/showcase.css";
|
||||
|
||||
/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */
|
||||
export function CaseCover({
|
||||
id,
|
||||
title,
|
||||
demoUrl,
|
||||
sizes = "(min-width: 1024px) 560px, 100vw",
|
||||
priority = false,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
sizes?: string;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const media = getCaseMedia(id, "desktop");
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||
<Image
|
||||
src={media.poster}
|
||||
alt={`Captura de tela do site ${title}`}
|
||||
fill
|
||||
sizes={sizes}
|
||||
priority={priority}
|
||||
className="object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { CaseMedia } from "./CaseMedia";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum.
|
||||
* A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/.
|
||||
*/
|
||||
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function withCaptures(devices: ReadonlyArray<"desktop" | "mobile">) {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
devices.includes(device) ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: "sev-modaintima",
|
||||
title: "SEV Moda Íntima",
|
||||
demoUrl: "https://sevmodaintima.com.br",
|
||||
};
|
||||
const desktopLabel = `Vídeo do site ${props.title} no computador`;
|
||||
const mobileLabel = `Vídeo do site ${props.title} no celular`;
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("CaseMedia", () => {
|
||||
it("path: desktop and mobile captures -> both frames, the browser bar shows the real domain", () => {
|
||||
withCaptures(["desktop", "mobile"]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(container.querySelector(".ds-bframe")).toContainElement(
|
||||
screen.getByLabelText(desktopLabel),
|
||||
);
|
||||
expect(container.querySelector(".ds-phone")).toContainElement(
|
||||
screen.getByLabelText(mobileLabel),
|
||||
);
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
expect(
|
||||
screen.getByText(`O site ${props.title} no computador.`),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("No celular.")).toBeVisible();
|
||||
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
|
||||
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "mobile");
|
||||
});
|
||||
|
||||
it("path: no capture at all -> renders nothing", () => {
|
||||
withCaptures([]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("path: only the mobile capture -> phone frame without the browser frame", () => {
|
||||
withCaptures(["mobile"]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(screen.getByLabelText(mobileLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(desktopLabel)).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(container.querySelector(".ds-phone")).not.toBeNull();
|
||||
expect(screen.queryByText(/no computador\./)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: only the desktop capture -> browser frame without the phone frame", () => {
|
||||
withCaptures(["desktop"]);
|
||||
const { container } = render(<CaseMedia {...props} />);
|
||||
|
||||
expect(screen.getByLabelText(desktopLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(mobileLabel)).toBeNull();
|
||||
expect(container.querySelector(".ds-phone")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).not.toBeNull();
|
||||
expect(screen.queryByText("No celular.")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no demo address -> the browser bar falls back to the project title", () => {
|
||||
withCaptures(["desktop"]);
|
||||
const { container } = render(
|
||||
<CaseMedia id={props.id} title={props.title} demoUrl={null} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
props.title,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
BrowserFrame,
|
||||
displayUrl,
|
||||
} from "@/app/components/showcase/BrowserFrame";
|
||||
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
|
||||
import type { ShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
import "../../components/showcase/showcase.css";
|
||||
|
||||
function CaseVideo({
|
||||
assets,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
assets: ShowcaseAssets;
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<video
|
||||
className={className}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
aria-label={label}
|
||||
disablePictureInPicture
|
||||
>
|
||||
{assets.sources.map((source) => (
|
||||
<source key={source.src} src={source.src} type={source.type} />
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular).
|
||||
* O vídeo só baixa quando a pessoa aperta o play. Sem captura gravada, não mostra nada.
|
||||
*/
|
||||
export function CaseMedia({
|
||||
id,
|
||||
title,
|
||||
demoUrl,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
}) {
|
||||
const desktop = getCaseMedia(id, "desktop");
|
||||
const mobile = getCaseMedia(id, "mobile");
|
||||
if (!desktop && !mobile) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-sp-8 grid items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
{desktop ? (
|
||||
<figure className="min-w-0">
|
||||
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||
<CaseVideo
|
||||
assets={desktop}
|
||||
label={`Vídeo do site ${title} no computador`}
|
||||
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
|
||||
O site {title} no computador.
|
||||
</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
{mobile ? (
|
||||
<figure className="mx-auto w-[min(62vw,220px)] md:mx-0">
|
||||
<PhoneFrame poster={mobile.poster}>
|
||||
<CaseVideo
|
||||
assets={mobile}
|
||||
label={`Vídeo do site ${title} no celular`}
|
||||
className="absolute inset-0 h-full w-full object-cover object-top"
|
||||
/>
|
||||
</PhoneFrame>
|
||||
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
|
||||
No celular.
|
||||
</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { secondaryButtonDark } from "@/app/components/button-styles";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { CaseMedia } from "./CaseMedia";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/** O bloco "Como ficou" existe quando há site no ar ou captura gravada, no computador ou no celular. */
|
||||
export function hasCaseMedia({
|
||||
id,
|
||||
demoUrl,
|
||||
}: Pick<CaseStudyContent, "id" | "demoUrl">) {
|
||||
return (
|
||||
Boolean(demoUrl) ||
|
||||
Boolean(getCaseMedia(id, "desktop")) ||
|
||||
Boolean(getCaseMedia(id, "mobile"))
|
||||
);
|
||||
}
|
||||
|
||||
/** "Como ficou": pôster e vídeo do projeto e o botão para abrir o site. Sem site nem captura, não mostra nada. */
|
||||
export function CaseMediaSection({
|
||||
caseStudy,
|
||||
}: {
|
||||
caseStudy: Pick<CaseStudyContent, "id" | "title" | "demoUrl">;
|
||||
}) {
|
||||
if (!hasCaseMedia(caseStudy)) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="ds-section ds-section-dark"
|
||||
aria-labelledby="case-media"
|
||||
>
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
|
||||
Como ficou
|
||||
</h2>
|
||||
<CaseMedia
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
/>
|
||||
{caseStudy.demoUrl ? (
|
||||
<div className="mt-sp-8">
|
||||
<a
|
||||
className={secondaryButtonDark}
|
||||
href={caseStudy.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site {caseStudy.title}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { textLink } from "@/app/components/button-styles";
|
||||
import { getRelatedServices } from "@/lib/site-content";
|
||||
|
||||
/** Links para os serviços que citam este case. Sem serviço relacionado, não mostra nada. */
|
||||
export function CaseRelatedServices({ caseId }: { caseId: string }) {
|
||||
const services = getRelatedServices(caseId);
|
||||
if (services.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-sp-6 flex flex-wrap gap-x-sp-6">
|
||||
{services.map((service) => (
|
||||
<Link
|
||||
key={service.slug}
|
||||
className={textLink}
|
||||
href={`/servicos/${service.slug}`}
|
||||
>
|
||||
Ver serviço relacionado: {service.navLabel}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Resultado do case, só com dado real e autorizado. Sem dado, a seção não aparece. */
|
||||
export function CaseResultSection({ result }: { result?: string[] }) {
|
||||
if (!result || result.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="ds-section" aria-labelledby="case-result">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 id="case-result" className="ds-card-title !mt-0">
|
||||
Resultado
|
||||
</h2>
|
||||
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
|
||||
{result.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/** Testes de caminhos-base de getCaseMedia: id inválido, pôster ausente e pôster presente em disco. */
|
||||
|
||||
jest.mock("node:fs", () => {
|
||||
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
|
||||
return { ...actual, existsSync: jest.fn(actual.existsSync) };
|
||||
});
|
||||
|
||||
const mockedExistsSync = jest.mocked(existsSync);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedExistsSync.mockClear();
|
||||
});
|
||||
|
||||
describe("getCaseMedia", () => {
|
||||
it.each([
|
||||
["with path traversal", "../../etc/passwd"],
|
||||
["with upper case", "Sev-Modaintima"],
|
||||
["with a slash", "sev/modaintima"],
|
||||
["empty", ""],
|
||||
])("path: id %s -> undefined without touching the disk", (_label, id) => {
|
||||
expect(getCaseMedia(id, "desktop")).toBeUndefined();
|
||||
expect(mockedExistsSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: valid id with no poster on disk -> undefined", () => {
|
||||
expect(getCaseMedia("twins-lab", "desktop")).toBeUndefined();
|
||||
expect(mockedExistsSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: valid id with the poster on disk -> the showcase assets of that device", () => {
|
||||
const desktop = getCaseMedia("sev-modaintima", "desktop");
|
||||
const mobile = getCaseMedia("sev-modaintima", "mobile");
|
||||
|
||||
expect(desktop?.poster).toBe("/videos/sev-modaintima/desktop.webp");
|
||||
expect(mobile?.poster).toBe("/videos/sev-modaintima/mobile.webp");
|
||||
expect(desktop?.sources.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type Device,
|
||||
getShowcaseAssets,
|
||||
type ShowcaseAssets,
|
||||
} from "@/lib/project-showcase";
|
||||
|
||||
const idPattern = /^[a-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* Pôster e vídeo gravados em public/videos/<id>/, ou `undefined` quando o projeto não tem captura.
|
||||
* Roda no servidor (na geração das páginas): olha se o pôster existe no disco.
|
||||
*/
|
||||
export function getCaseMedia(
|
||||
id: string,
|
||||
device: Device,
|
||||
): ShowcaseAssets | undefined {
|
||||
if (!idPattern.test(id)) return undefined;
|
||||
const assets = getShowcaseAssets(id, device);
|
||||
const file = join(process.cwd(), "public", assets.poster);
|
||||
return existsSync(file) ? assets : undefined;
|
||||
}
|
||||
Reference in new issue
Block a user