feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
This commit is contained in:
1 parent
4671837c44
commit
c7d09d63d4
165 files changed
+7940
-3790
No files matched your search
@@ -126,7 +126,7 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
|
||||
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
||||
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
|
||||
const contacts = screen.getAllByRole("link", {
|
||||
name: "Falar sobre meu projeto",
|
||||
name: "Chamar no WhatsApp",
|
||||
});
|
||||
expect(contacts).toHaveLength(1);
|
||||
expect(contacts[0]).toBeVisible();
|
||||
|
||||
@@ -62,8 +62,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, com o mesmo rótulo
|
||||
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
||||
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos.
|
||||
* ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
||||
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos.
|
||||
*/
|
||||
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||
const hrefs = screen
|
||||
@@ -72,7 +72,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||
expect(hrefs).not.toContain("/#contact");
|
||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||
|
||||
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
|
||||
const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp });
|
||||
expect(ctas).toHaveLength(1);
|
||||
for (const cta of ctas) {
|
||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||
@@ -179,13 +179,13 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
const capabilitiesSection = sectionHeadings[4].closest("section");
|
||||
expect(
|
||||
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
name: buttonLabels.whatsapp,
|
||||
}),
|
||||
).toBeNull();
|
||||
const closingSection = sectionHeadings[5].closest("section");
|
||||
expect(closingSection).toContainElement(
|
||||
within(closingSection as HTMLElement).getByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
name: buttonLabels.whatsapp,
|
||||
}),
|
||||
);
|
||||
for (const capability of caseStudy.capabilities ?? []) {
|
||||
@@ -371,10 +371,10 @@ it("writes what the client can do now right after 'Como ficou', with no contact
|
||||
expect(items.length).toBeLessThanOrEqual(3);
|
||||
// sem número inventado
|
||||
expect(items.join(" ")).not.toMatch(/\d/);
|
||||
// o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos
|
||||
// o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos
|
||||
expect(
|
||||
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
name: buttonLabels.whatsapp,
|
||||
}),
|
||||
).toBeNull();
|
||||
const closingSection = screen
|
||||
@@ -386,7 +386,7 @@ it("writes what the client can do now right after 'Como ficou', with no contact
|
||||
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
|
||||
expect(
|
||||
within(closingSection as HTMLElement).getAllByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
name: buttonLabels.whatsapp,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
unmount();
|
||||
@@ -477,7 +477,7 @@ it("shows at most two related services, as cards under the contact button", asyn
|
||||
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||
).toBeNull();
|
||||
// o botão do fecho (o único) vem antes dos cartões
|
||||
const contact = screen.getByRole("link", { name: buttonLabels.contact });
|
||||
const contact = screen.getByRole("link", { name: buttonLabels.whatsapp });
|
||||
expect(isBefore(contact, cards[0])).toBe(true);
|
||||
unmount();
|
||||
|
||||
@@ -748,7 +748,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
||||
expect(hrefs).not.toContain("/#contact");
|
||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||
expect(hrefs).toContain("/servicos");
|
||||
const cta = screen.getByRole("link", { name: buttonLabels.contact });
|
||||
const cta = screen.getByRole("link", { name: buttonLabels.whatsapp });
|
||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
|
||||
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto"
|
||||
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp"
|
||||
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("CaseCapabilitiesSection", () => {
|
||||
for (const item of capabilities) {
|
||||
expect(screen.getByText(item)).toBeVisible();
|
||||
}
|
||||
// o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção
|
||||
// o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(container.querySelectorAll("a")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CaseStudyContent } from "@/lib/site-content";
|
||||
/**
|
||||
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
|
||||
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
|
||||
* "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece.
|
||||
* "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece.
|
||||
*/
|
||||
export function CaseCapabilitiesSection({
|
||||
caseStudy,
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("CaseContactSection", () => {
|
||||
name: "Quer um projeto parecido com este?",
|
||||
}),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
||||
expect(messageOf(cta)).toContain(
|
||||
@@ -48,7 +48,7 @@ describe("CaseContactSection", () => {
|
||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||
).toBeVisible();
|
||||
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
||||
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
||||
|
||||
@@ -10,8 +10,8 @@ import { CaseRelatedServices } from "./CaseRelatedServices";
|
||||
|
||||
/**
|
||||
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
|
||||
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
|
||||
* (e no botão do cabeçalho): `similarProjectSubject`.
|
||||
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
|
||||
* `similarProjectSubject`.
|
||||
*/
|
||||
const contactCopy = {
|
||||
client: { title: "Quer um projeto parecido com este?" },
|
||||
@@ -47,7 +47,7 @@ export function CaseContactSection({
|
||||
placement="case_detail"
|
||||
className={primaryButton}
|
||||
>
|
||||
{buttonLabels.contact}
|
||||
{buttonLabels.whatsapp}
|
||||
</WhatsAppLink>
|
||||
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||
Ver outros cases
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import { CaseCover, coverSizes } from "./CaseCover";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
import { coverTransitionProps } from "./cover-transition";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
|
||||
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
|
||||
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
|
||||
* os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`)
|
||||
* e o pôster como elemento compartilhado da troca de página.
|
||||
*/
|
||||
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
|
||||
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
|
||||
jest.mock("react", () =>
|
||||
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
|
||||
);
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
const props = {
|
||||
id: "sev-modaintima",
|
||||
@@ -22,6 +30,23 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CaseCover", () => {
|
||||
it("path: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
|
||||
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||
render(<CaseCover {...props} />);
|
||||
|
||||
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||
const transition = image.parentElement;
|
||||
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
|
||||
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
|
||||
expect(transition).toHaveAttribute("data-vt-default", "none");
|
||||
});
|
||||
|
||||
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
|
||||
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
|
||||
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it("path: no desktop capture -> renders nothing", () => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
const { container } = render(<CaseCover {...props} />);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import { ViewTransition } from "react";
|
||||
import {
|
||||
BrowserFrame,
|
||||
displayUrl,
|
||||
} from "@/app/components/showcase/BrowserFrame";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
import { coverTransitionProps } from "./cover-transition";
|
||||
|
||||
/**
|
||||
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
|
||||
@@ -54,14 +56,16 @@ export function CaseCover({
|
||||
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}
|
||||
{...priorityProps}
|
||||
className="object-cover object-top"
|
||||
/>
|
||||
<ViewTransition {...coverTransitionProps(id)}>
|
||||
<Image
|
||||
src={media.poster}
|
||||
alt={`Captura de tela do site ${title}`}
|
||||
fill
|
||||
sizes={sizes}
|
||||
{...priorityProps}
|
||||
className="object-cover object-top"
|
||||
/>
|
||||
</ViewTransition>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
);
|
||||
|
||||
@@ -70,9 +70,9 @@ export function CaseHero({
|
||||
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
||||
]}
|
||||
/>
|
||||
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
|
||||
<p className="ds-eyebrow">
|
||||
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
|
||||
{caseStudy.title}
|
||||
{isClient ? "Case de cliente" : "Projeto de laboratório"}
|
||||
</p>
|
||||
<h1 className="ds-section-title text-balance text-white">
|
||||
{caseStudy.headline}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function CaseMediaSection({
|
||||
className={secondaryButtonDark}
|
||||
href={caseStudy.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{buttonLabels.site}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
|
||||
@@ -27,9 +27,18 @@ describe("CasePlayer", () => {
|
||||
name: `Reproduzir o vídeo do site ${title} no computador`,
|
||||
});
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
// só botão de contato é limão: o de play é escuro, com contorno branco
|
||||
expect(button.innerHTML).not.toContain("--slash");
|
||||
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
|
||||
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
|
||||
const circle = button.querySelector("[aria-hidden='true']");
|
||||
const restClasses = (circle?.className ?? "")
|
||||
.split(" ")
|
||||
.filter((name) => !name.startsWith("group-"));
|
||||
expect(restClasses.join(" ")).not.toContain("--slash");
|
||||
expect(circle).toHaveClass(
|
||||
"group-hover:scale-108",
|
||||
"group-hover:bg-[var(--slash)]",
|
||||
"group-focus-visible:scale-108",
|
||||
"group-focus-visible:bg-[var(--slash)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
|
||||
|
||||
@@ -78,9 +78,10 @@ export function CasePlayer({
|
||||
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
|
||||
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
|
||||
>
|
||||
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
|
||||
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[scale] duration-[280ms] ease-[var(--ease-out)] group-hover:scale-108 group-hover:border-[var(--slash)] group-hover:bg-[var(--slash)] group-hover:text-[var(--ink)] group-focus-visible:scale-108 group-focus-visible:border-[var(--slash)] group-focus-visible:bg-[var(--slash)] group-focus-visible:text-[var(--ink)] motion-reduce:transition-none sm:size-16"
|
||||
>
|
||||
<Play className="ml-1 size-6" fill="currentColor" />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* A capa como elemento compartilhado da troca de página: com o mesmo nome na lista (home, /cases) e no topo do case,
|
||||
* ela viaja de um lugar ao outro (classe case-cover em globals.css). Só a troca entre as duas anima
|
||||
* (`default="none"`). Duas instâncias com o mesmo nome na mesma página cancelam a transição: cada página mostra
|
||||
* a capa de um case uma vez só. Fica fora de CaseCover porque a lista da home é componente de cliente, e
|
||||
* CaseCover lê as capturas só no servidor.
|
||||
*/
|
||||
export function coverTransitionProps(id: string) {
|
||||
return { name: `case-${id}`, share: "case-cover", default: "none" } as const;
|
||||
}
|
||||
@@ -43,11 +43,14 @@ describe("CasesPage: carregamento das capas", () => {
|
||||
it("loads the first card cover at once with high priority, and every other image lazily", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
|
||||
// sem mosaico: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade
|
||||
const decorative = Array.from(
|
||||
container.querySelectorAll<HTMLImageElement>("img"),
|
||||
).filter((image) => image.getAttribute("alt") === "");
|
||||
expect(decorative).toHaveLength(0);
|
||||
expect(decorative).toHaveLength(1);
|
||||
expect(decorative[0].getAttribute("src")).toContain("estado-tela.webp");
|
||||
expect(decorative[0]).toHaveAttribute("loading", "lazy");
|
||||
expect(decorative[0]).not.toHaveAttribute("fetchpriority");
|
||||
|
||||
const [first, ...others] = coverImages(container);
|
||||
expect(first).toHaveAttribute("loading", "eager");
|
||||
|
||||
@@ -77,11 +77,27 @@ describe("CasesPage: cases de cliente", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
|
||||
it("path: an odd number of client cases -> the last card takes the whole row on wide screens, so the grid ends with no empty cell", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const wide = clientCasesForListing.map((caseStudy) =>
|
||||
clientCard(container, caseStudy.id).className.includes(
|
||||
"min-[1025px]:col-span-2",
|
||||
),
|
||||
);
|
||||
const odd = clientCasesForListing.length % 2 === 1;
|
||||
expect(wide).toEqual(
|
||||
clientCasesForListing.map(
|
||||
(_, index) => odd && index === clientCasesForListing.length - 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: contact -> 'Chamar no WhatsApp', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: buttonLabels.contact }),
|
||||
screen.getByRole("link", { name: buttonLabels.whatsapp }),
|
||||
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
||||
const clientTitles = Array.from(
|
||||
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
|
||||
@@ -98,18 +114,24 @@ describe("CasesPage: cases de cliente", () => {
|
||||
]);
|
||||
// um botão de contato só na página: o do fecho
|
||||
expect(
|
||||
screen.getAllByRole("link", { name: buttonLabels.contact }),
|
||||
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CasesPage: topo sem mosaico", () => {
|
||||
it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => {
|
||||
it("path: captures found -> no poster mosaic (the cards below already carry the covers), only the block piece beside the text, a lower strip, no 'Veja o que já' twice", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector("img")).toBeNull();
|
||||
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
|
||||
const images = hero?.querySelectorAll("img") ?? [];
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].getAttribute("src")).toContain("estado-tela.webp");
|
||||
// decorativa e só de 1024 px em diante
|
||||
expect(images[0].closest('[aria-hidden="true"]')).toHaveClass(
|
||||
"hidden",
|
||||
"lg:block",
|
||||
);
|
||||
expect(hero?.className).toContain("md:!pb-sp-12");
|
||||
// o título e o subtítulo não repetem a mesma frase
|
||||
const heading = within(hero as HTMLElement).getByRole("heading", {
|
||||
@@ -130,7 +152,7 @@ describe("CasesPage: topo sem mosaico", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector("img")).toBeNull();
|
||||
expect(hero?.querySelectorAll("img")).toHaveLength(1);
|
||||
const firstCover = clientCard(
|
||||
container,
|
||||
clientCasesForListing[0].id,
|
||||
|
||||
+45
-28
@@ -8,6 +8,7 @@ import {
|
||||
textLink,
|
||||
} from "@/app/components/button-styles";
|
||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||
import { PagePiece } from "@/app/components/PagePiece";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from "@/lib/site-content";
|
||||
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
||||
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
||||
import { compactHero } from "./_components/hero-styles";
|
||||
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
|
||||
|
||||
const pageUrl = `${siteUrl}/cases`;
|
||||
const pageTitle = "Cases: sites e sistemas para empresas";
|
||||
@@ -42,45 +43,60 @@ export const metadata: Metadata = {
|
||||
}),
|
||||
};
|
||||
|
||||
/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
|
||||
/**
|
||||
* Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case.
|
||||
* `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita.
|
||||
*/
|
||||
function ClientCaseCard({
|
||||
caseStudy,
|
||||
priority = false,
|
||||
wide = false,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
||||
priority?: boolean;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article className="ds-card !h-auto">
|
||||
<article
|
||||
className={`ds-card !h-auto ${
|
||||
// a partir de 1025 px, onde .ds-grid-2 (globals.css) passa a duas colunas; o "!" vence o display:flex de .ds-card,
|
||||
// que fica fora das camadas do Tailwind
|
||||
wide
|
||||
? "min-[1025px]:col-span-2 min-[1025px]:!grid min-[1025px]:grid-cols-2 min-[1025px]:items-center min-[1025px]:gap-x-sp-8 min-[1025px]:[&>:first-child]:mb-0"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<HoverPrefetchLink
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
{buttonLabels.case}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</HoverPrefetchLink>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
<div>
|
||||
<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">
|
||||
<HoverPrefetchLink
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
{buttonLabels.site}
|
||||
{buttonLabels.case}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</a>
|
||||
) : null}
|
||||
</HoverPrefetchLink>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{buttonLabels.site}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -144,15 +160,14 @@ export default function CasesPage() {
|
||||
<section
|
||||
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||
>
|
||||
<div className="ds-section-shell">
|
||||
<div className="max-w-[860px]">
|
||||
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
|
||||
<div className="min-w-0 max-w-[860px]">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Início", href: "/" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
]}
|
||||
/>
|
||||
<p className="ds-eyebrow">Trabalhos entregues</p>
|
||||
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
||||
Veja o que já fiz para empresas.
|
||||
</h1>
|
||||
@@ -160,6 +175,7 @@ export default function CasesPage() {
|
||||
{pageLead}
|
||||
</p>
|
||||
</div>
|
||||
<PagePiece image="/3d/estado-tela.webp" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -169,11 +185,12 @@ export default function CasesPage() {
|
||||
Projetos de clientes
|
||||
</h2>
|
||||
<div className="ds-grid-2 mt-sp-8 items-start">
|
||||
{clientCasesForListing.map((caseStudy, index) => (
|
||||
{clientCasesForListing.map((caseStudy, index, all) => (
|
||||
<ClientCaseCard
|
||||
key={caseStudy.id}
|
||||
caseStudy={caseStudy}
|
||||
priority={index === 0}
|
||||
wide={index === all.length - 1 && all.length % 2 === 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -211,7 +228,7 @@ export default function CasesPage() {
|
||||
placement="cases_hub_footer"
|
||||
className={primaryButton}
|
||||
>
|
||||
{buttonLabels.contact}
|
||||
{buttonLabels.whatsapp}
|
||||
</WhatsAppLink>
|
||||
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
||||
{buttonLabels.allServices}
|
||||
|
||||
Reference in new issue
Block a user