feat(cases): mídia no topo, títulos de busca curtos e CreativeWork
Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
1 parent
c9a0b80656
commit
e80f73d075
52 files changed
+2593
-439
No files matched your search
@@ -4,6 +4,8 @@ import {
|
||||
caseStudies,
|
||||
getRelatedCases,
|
||||
getServicePage,
|
||||
isClientCase,
|
||||
servicePages,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import ServicesIndexPage from "../page";
|
||||
@@ -62,16 +64,16 @@ it("keeps static params and route metadata canonical", async () => {
|
||||
const url = `${siteUrl}/servicos/${service.slug}`;
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
},
|
||||
});
|
||||
@@ -192,7 +194,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
||||
]);
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
name: "Breadcrumb",
|
||||
name: "Trilha de navegação",
|
||||
});
|
||||
expect(
|
||||
within(visibleBreadcrumb)
|
||||
@@ -273,7 +275,7 @@ it("shows the client cases first, right after the hero, with the first cover in
|
||||
|
||||
const casesHeading = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Veja projetos parecidos, já prontos",
|
||||
name: "Onde já apliquei isso",
|
||||
});
|
||||
expect(
|
||||
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
|
||||
@@ -311,9 +313,7 @@ it("hides the related block and the hero frame when a service has no case", asyn
|
||||
const { container } = await renderService("tauri-rust");
|
||||
|
||||
expect(
|
||||
screen.queryByRole("heading", {
|
||||
name: "Veja projetos parecidos, já prontos",
|
||||
}),
|
||||
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
|
||||
expect(
|
||||
@@ -331,11 +331,122 @@ it("labels the Twins Lab prototype as a laboratory project on the IoT service",
|
||||
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/cases/twins-lab",
|
||||
]);
|
||||
expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible();
|
||||
expect(screen.getByText("Twins Lab · Projeto próprio")).toBeVisible();
|
||||
expect(screen.queryByText(/Protótipo de laboratório/)).toBeNull();
|
||||
// sem captura gravada, não há moldura nem imagem
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels only the laboratory projects as 'Projeto próprio'", async () => {
|
||||
for (const slug of ["criacao-de-sites", "integracao-hardware-software-iot"]) {
|
||||
const { unmount } = await renderService(slug);
|
||||
const related = getRelatedCases(getServicePage(slug)?.relatedCaseIds ?? []);
|
||||
expect(related.length).toBeGreaterThan(0);
|
||||
const labels = screen.queryAllByText(/· Projeto próprio$/);
|
||||
expect(labels).toHaveLength(related.filter((c) => !isClientCase(c)).length);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("says in one sentence, per case, why it matters to the service", async () => {
|
||||
for (const { slug, relatedCaseIds, caseRelevance } of servicePages) {
|
||||
if (relatedCaseIds.length === 0) continue;
|
||||
const { unmount } = await renderService(slug);
|
||||
|
||||
for (const id of relatedCaseIds) {
|
||||
const sentence = caseRelevance[id];
|
||||
expect(sentence).toBeTruthy();
|
||||
const card = screen.getByText(sentence).closest("article");
|
||||
expect(card).toContainElement(
|
||||
screen.getByRole("link", {
|
||||
name: `Ler case: ${caseStudies.find((c) => c.id === id)?.title}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("cites the Apolo Energia experience in text on the IoT and architecture pages, with no link", async () => {
|
||||
for (const slug of [
|
||||
"integracao-hardware-software-iot",
|
||||
"arquitetura-de-software",
|
||||
]) {
|
||||
const note = getServicePage(slug)?.experienceNote ?? "";
|
||||
const { container, unmount } = await renderService(slug);
|
||||
|
||||
const paragraph = screen.getByText(/Apolo Energia/);
|
||||
expect(paragraph).toBeVisible();
|
||||
expect(paragraph.textContent).toContain(note);
|
||||
expect(paragraph.textContent).toMatch(/Desde 2022/);
|
||||
expect(paragraph.closest("a")).toBeNull();
|
||||
expect(paragraph.querySelector("a")).toBeNull();
|
||||
// dentro do bloco "Onde já apliquei isso", antes dos cards
|
||||
const block = screen.getByRole("heading", {
|
||||
name: "Onde já apliquei isso",
|
||||
});
|
||||
expect(isBefore(block, paragraph)).toBe(true);
|
||||
expect(
|
||||
isBefore(
|
||||
paragraph,
|
||||
container.querySelector("article.ds-card") as Element,
|
||||
),
|
||||
).toBe(true);
|
||||
const hrefs = screen
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href") ?? "");
|
||||
expect(hrefs.some((href) => /apolo/i.test(href))).toBe(false);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not mention Apolo Energia on the other service pages", async () => {
|
||||
for (const slug of [
|
||||
"criacao-de-sites",
|
||||
"engenharia-de-software",
|
||||
"consultoria-nextjs",
|
||||
"tauri-rust",
|
||||
]) {
|
||||
const { container, unmount } = await renderService(slug);
|
||||
expect(container.textContent).not.toMatch(/Apolo/);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the FAQ before the next step and ends every service page with a contact button", async () => {
|
||||
for (const { slug, faq, ctaLabel } of servicePages) {
|
||||
const { container, unmount } = await renderService(slug);
|
||||
|
||||
const nextStep = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Conte o que você precisa resolver.",
|
||||
});
|
||||
if (faq?.length) {
|
||||
const faqHeading = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Perguntas frequentes",
|
||||
});
|
||||
expect(isBefore(faqHeading, nextStep)).toBe(true);
|
||||
}
|
||||
|
||||
// o botão de contato vem depois das dúvidas: o último elemento da página é a seção do próximo passo
|
||||
const last = container.querySelector("main")?.lastElementChild;
|
||||
expect(last).toContainElement(nextStep);
|
||||
const cta = within(last as HTMLElement).getByRole("link", {
|
||||
name: `${ctaLabel} no WhatsApp`,
|
||||
});
|
||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
|
||||
"https://wa.me",
|
||||
);
|
||||
for (const question of faq ?? []) {
|
||||
expect(
|
||||
isBefore(screen.getByRole("heading", { name: question.question }), cta),
|
||||
).toBe(true);
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the offer block only when some field is filled in", async () => {
|
||||
const empty = await renderService("criacao-de-sites");
|
||||
expect(
|
||||
|
||||
@@ -51,11 +51,11 @@ export async function generateMetadata({
|
||||
const url = `${siteUrl}/servicos/${service.slug}`;
|
||||
|
||||
return {
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
...buildPageSocialMetadata({
|
||||
url,
|
||||
title: service.seoTitle,
|
||||
title: service.metaTitle,
|
||||
description: service.metaDescription,
|
||||
type: "article",
|
||||
}),
|
||||
@@ -129,7 +129,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-sp-4 text-sm leading-6 text-white/70">
|
||||
<p className="mt-sp-4 text-[15px] md:text-sm 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.
|
||||
@@ -139,7 +139,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ServiceRelatedCases cases={relatedCases} />
|
||||
<ServiceRelatedCases
|
||||
cases={relatedCases}
|
||||
relevance={service.caseRelevance}
|
||||
experienceNote={service.experienceNote}
|
||||
/>
|
||||
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
@@ -171,7 +175,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-sm leading-8 text-[var(--ink-80)]">
|
||||
<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)]">
|
||||
{service.process.map((step) => (
|
||||
<li key={step} className="pl-sp-2">
|
||||
{step}
|
||||
@@ -224,7 +228,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
|
||||
<ServiceOffer service={service} />
|
||||
|
||||
<section className="ds-section ds-section-dark pb-sp-6">
|
||||
<ServiceFaq faq={service.faq} />
|
||||
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
|
||||
<div className="ds-kicker">Próximo passo</div>
|
||||
@@ -252,8 +258,6 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ServiceFaq faq={service.faq} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { caseStudies, isClientCase } from "@/lib/site-content";
|
||||
import { ServiceRelatedCases } from "./ServiceRelatedCases";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ServiceRelatedCases, direto no componente:
|
||||
* sem case nem experiência, só a experiência, case sem frase de relação, case de laboratório,
|
||||
* case sem captura e case sem site no ar. A existência dos arquivos em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||
getCaseMedia: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
function withDesktopCapture() {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
device === "desktop" ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const sev = getCase("sev-modaintima"); // cliente, com site no ar
|
||||
const asServicos = getCase("as-servicos"); // cliente, sem site público
|
||||
const twins = getCase("twins-lab"); // laboratório, sem site público
|
||||
const TITLE = "Onde já apliquei isso";
|
||||
const NOTE = "Texto de experiência de teste.";
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("ServiceRelatedCases", () => {
|
||||
it("path: no case and no experience note -> renders nothing", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("path: no case but an experience note -> keeps the block with the note and no case card", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={NOTE} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: TITLE }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(NOTE)).toBeVisible();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
|
||||
withDesktopCapture();
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases
|
||||
cases={[sev]}
|
||||
relevance={{ [sev.id]: "Frase de relação do SEV." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: TITLE }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Na prática:")).toBeNull();
|
||||
const card = container.querySelector("article");
|
||||
if (!card) throw new Error("Missing case card");
|
||||
expect(within(card).getByText(sev.headline)).toBeVisible();
|
||||
// case de cliente: o kicker é só o nome
|
||||
expect(within(card).getByText(sev.title)).toBeVisible();
|
||||
expect(within(card).queryByText(/Projeto próprio/)).toBeNull();
|
||||
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
|
||||
expect(
|
||||
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
).toHaveAttribute("href", `/cases/${sev.id}`);
|
||||
expect(
|
||||
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
|
||||
).toHaveAttribute("href", sev.demoUrl);
|
||||
});
|
||||
|
||||
it("path: cases and an experience note -> the note comes first, in text only, with no link", () => {
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[sev]}
|
||||
relevance={{ [sev.id]: "Frase de relação do SEV." }}
|
||||
experienceNote={NOTE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const note = screen.getByText(NOTE, { exact: false });
|
||||
expect(screen.getByText("Na prática:")).toBeVisible();
|
||||
expect(note.closest("a")).toBeNull();
|
||||
expect(note.querySelector("a")).toBeNull();
|
||||
expect(
|
||||
note.compareDocumentPosition(
|
||||
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("path: case with a desktop capture -> cover link with the poster, hidden from assistive tech", () => {
|
||||
withDesktopCapture();
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
|
||||
);
|
||||
|
||||
const coverLink = container.querySelector(
|
||||
`article a[aria-hidden="true"][href="/cases/${sev.id}"]`,
|
||||
);
|
||||
expect(coverLink).not.toBeNull();
|
||||
expect(coverLink).toHaveAttribute("tabindex", "-1");
|
||||
expect(coverLink?.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||
"alt",
|
||||
`Captura de tela do site ${sev.title}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: case without a desktop capture -> no cover link, text and buttons stay", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("a[aria-hidden='true']")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: laboratory case -> kicker marks it as 'Projeto próprio'", () => {
|
||||
expect(isClientCase(twins)).toBe(false);
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[twins]}
|
||||
relevance={{ [twins.id]: "Frase." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
|
||||
render(<ServiceRelatedCases cases={[sev]} relevance={{}} />);
|
||||
|
||||
const card = screen.getByRole("heading", { level: 3 }).closest("article");
|
||||
expect(card).not.toBeNull();
|
||||
expect(within(card as HTMLElement).getByText(sev.summary)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: case without a public site -> no 'Abrir o site' link", () => {
|
||||
expect(asServicos.demoUrl).toBeNull();
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[asServicos]}
|
||||
relevance={{ [asServicos.id]: "Frase." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import { ServiceRelatedCases } from "./ServiceRelatedCases";
|
||||
|
||||
const caseStudy = caseStudies[0];
|
||||
const note = "Desde 2022 trabalho com isso, em sistemas internos.";
|
||||
|
||||
it("renders nothing without cases and without an experience note", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows only the experience note, without the 'open the case' sentence, when there are no cases", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={note} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(note)).toBeVisible();
|
||||
expect(screen.queryByText(/Abra o case/)).toBeNull();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(container.querySelector(".ds-grid-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("invites to open the case and lists each case when there are some", () => {
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[caseStudy]}
|
||||
relevance={{ [caseStudy.id]: "Frase própria sobre a relação." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Abra o case/)).toBeVisible();
|
||||
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }),
|
||||
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
|
||||
});
|
||||
|
||||
it("falls back to the case summary when no relevance sentence is given", () => {
|
||||
render(<ServiceRelatedCases cases={[caseStudy]} relevance={{}} />);
|
||||
|
||||
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||
});
|
||||
@@ -1,16 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { CaseCover } from "@/app/cases/_components/CaseCover";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
|
||||
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
|
||||
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
|
||||
|
||||
/** "Veja projetos parecidos": os cases ligados ao serviço. Sem case relacionado, não mostra nada. */
|
||||
function ServiceCaseCard({
|
||||
caseStudy,
|
||||
relevance,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
/** Por que este case interessa ao serviço; sem frase própria, vale o resumo do case. */
|
||||
relevance?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<CaseCoverLink
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
/>
|
||||
<div className="ds-kicker">
|
||||
{isClientCase(caseStudy)
|
||||
? caseStudy.title
|
||||
: `${caseStudy.title} · Projeto próprio`}
|
||||
</div>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<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}>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site: {caseStudy.title}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma frase sobre o que ele tem a ver com ele,
|
||||
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase
|
||||
* que manda abrir o case; sem case nem experiência, não mostra nada.
|
||||
*/
|
||||
export function ServiceRelatedCases({
|
||||
cases,
|
||||
relevance,
|
||||
experienceNote,
|
||||
}: {
|
||||
cases: readonly CaseStudyContent[];
|
||||
/** Por que cada case interessa a este serviço (chave: id do case). */
|
||||
relevance: Readonly<Record<string, string>>;
|
||||
experienceNote?: string;
|
||||
}) {
|
||||
if (cases.length === 0) return null;
|
||||
const hasCases = cases.length > 0;
|
||||
if (!hasCases && !experienceNote) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -23,61 +74,37 @@ export function ServiceRelatedCases({
|
||||
<div className="ds-section-meta">
|
||||
<p className="ds-eyebrow">Trabalhos parecidos</p>
|
||||
<h2 id="casos-relacionados-titulo" className="ds-section-title">
|
||||
Veja projetos parecidos, já prontos
|
||||
Onde já apliquei isso
|
||||
</h2>
|
||||
<p className="ds-section-description">
|
||||
Projetos reais nesta área. Abra o case para ver o que foi feito e
|
||||
como ficou.
|
||||
</p>
|
||||
{hasCases ? (
|
||||
<p className="ds-section-description">
|
||||
Cada projeto tem a ver com este serviço de um jeito. Abra o case
|
||||
para ver o que foi feito e como ficou.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ds-grid-2">
|
||||
{cases.map((caseStudy) => (
|
||||
<article key={caseStudy.id} className="ds-card">
|
||||
{getCaseMedia(caseStudy.id, "desktop") ? (
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="ds-kicker">
|
||||
{isClientCase(caseStudy)
|
||||
? caseStudy.title
|
||||
: `${caseStudy.title} · Protótipo de laboratório`}
|
||||
</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
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site: {caseStudy.title}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{experienceNote ? (
|
||||
<p className="ds-copy mb-sp-8 max-w-[72ch]">
|
||||
<strong className="font-semibold text-[var(--ink)]">
|
||||
Na prática:
|
||||
</strong>{" "}
|
||||
{experienceNote}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasCases ? (
|
||||
<div className="ds-grid-2">
|
||||
{cases.map((caseStudy) => (
|
||||
<ServiceCaseCard
|
||||
key={caseStudy.id}
|
||||
caseStudy={caseStudy}
|
||||
relevance={relevance[caseStudy.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+9
-20
@@ -1,7 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CaseCover } from "@/app/cases/_components/CaseCover";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
|
||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import {
|
||||
pageTop,
|
||||
@@ -15,9 +14,9 @@ import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content";
|
||||
|
||||
const pageUrl = `${siteUrl}/servicos`;
|
||||
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas";
|
||||
const pageTitle = "Sites, sistemas e aplicativos sob medida";
|
||||
const pageDescription =
|
||||
"Sites, sistemas web e aplicativos sob medida para sua empresa apresentar o negócio e organizar a operação. Evandro, desenvolvedor em Garuva-SC, atende Joinville, região e todo o Brasil de forma remota.";
|
||||
"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.";
|
||||
|
||||
const businessIntents = [
|
||||
{
|
||||
@@ -188,21 +187,11 @@ export default function ServicesIndexPage() {
|
||||
key={caseStudy.id}
|
||||
className="border-t border-[var(--border)] pt-sp-6"
|
||||
>
|
||||
{getCaseMedia(caseStudy.id, "desktop") ? (
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<CaseCoverLink
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
/>
|
||||
<p className="ds-kicker">{caseStudy.title}</p>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
@@ -241,7 +230,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-sm">
|
||||
<dl className="mt-sp-5 space-y-sp-6 text-[15px] md:text-sm">
|
||||
<div>
|
||||
<dt className="font-semibold">
|
||||
Quanto custa e quanto tempo leva?
|
||||
|
||||
Reference in new issue
Block a user