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:
Evandro Fonseca Junior committed 2026-10-14 03:31:57 -03:00
1 parent 3756905a1b
commit 6e1022b58c
44 files changed
+2691 -1424

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { getServicePage } from "@/lib/site-content";
import ServicePage, { generateMetadata } from "./page";
/**
* Testes de caminhos-base de ServicePage e generateMetadata (complemento de page.test.tsx).
* Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias.
*/
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
}));
beforeEach(() => {
jest.mocked(notFound).mockClear();
});
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
return render(ui);
}
const FAQ_HEADING = "Perguntas frequentes";
describe("ServicePage: caminhos que o teste principal não cobre", () => {
it("path: unknown slug -> calls notFound() and renders nothing", async () => {
await expect(
ServicePage({ params: Promise.resolve({ slug: "nao-existe" }) }),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(notFound).toHaveBeenCalledTimes(1);
});
it("path: service with a FAQ (main path) -> shows every question", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq?.length) throw new Error("Missing service FAQ");
await renderService(service.slug);
expect(
screen.getByRole("heading", { level: 2, name: FAQ_HEADING }),
).toBeVisible();
for (const item of service.faq) {
expect(
screen.getByRole("heading", { level: 3, name: item.question }),
).toBeVisible();
}
});
it("path: service without the faq field -> no FAQ section, the rest of the page stays", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq) throw new Error("Missing service FAQ");
const originalFaq = service.faq;
delete service.faq;
try {
await renderService(service.slug);
expect(screen.queryByRole("heading", { name: FAQ_HEADING })).toBeNull();
expect(
screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
}),
).toBeVisible();
} finally {
service.faq = originalFaq;
}
});
it("path: service with an empty FAQ list -> no FAQ section", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq) throw new Error("Missing service FAQ");
const originalFaq = service.faq;
service.faq = [];
try {
await renderService(service.slug);
expect(screen.queryByRole("heading", { name: FAQ_HEADING })).toBeNull();
expect(
screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
}),
).toBeVisible();
} finally {
service.faq = originalFaq;
}
});
});
describe("generateMetadata: caminho do slug desconhecido", () => {
it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => {
const metadata = await generateMetadata({
params: Promise.resolve({ slug: "nao-existe" }),
});
expect(metadata).toEqual({ title: "Serviço não encontrado" });
});
});
+148 -26
View File
@@ -1,33 +1,26 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { SEO_IDS } from "@/lib/seo";
import { caseStudies, getServicePage, siteUrl } from "@/lib/site-content";
import {
caseStudies,
getRelatedCases,
getServicePage,
siteUrl,
} from "@/lib/site-content";
import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
return render(ui);
}
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
function isBefore(first: Element, second: Element) {
return Boolean(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
const service = getServicePage("consultoria-nextjs");
if (!service) throw new Error("Missing representative service");
@@ -86,10 +79,7 @@ it("keeps static params and route metadata canonical", async () => {
});
it("renders answer-first consulting, reciprocal case links and minimal schema", async () => {
const ui = await ServicePage({
params: Promise.resolve({ slug: service.slug }),
});
const { container } = render(ui);
const { container } = await renderService(service.slug);
const answer = screen.getByText(service.answer);
expect(answer).toBeVisible();
@@ -153,7 +143,15 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
"rel",
expect.stringMatching(/noreferrer/),
);
expect(externalLink.getAttribute("href")).not.toMatch(/github/i);
}
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(container.textContent).not.toMatch(
/Exemplos ilustrativos|não projetos já entregues|Sem promessas|indispon|Ver código/i,
);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
@@ -243,3 +241,127 @@ it("keeps hub cards and structured items aligned", () => {
],
});
});
it("shows the client projects with covers on the hub, without disclaimers", () => {
const { container } = render(<ServicesIndexPage />);
for (const id of [
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
]) {
const link = container.querySelector(`a[href="/cases/${id}"]`);
expect(
link?.closest("article")?.querySelector(".ds-bframe img"),
).not.toBeNull();
}
expect(container.textContent).not.toMatch(
/sem promessas|Demonstração publicada|limites|indispon/i,
);
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
});
it("shows the client cases first, right after the hero, with the first cover in a frame", async () => {
const { container } = await renderService("criacao-de-sites");
const siteService = getServicePage("criacao-de-sites");
if (!siteService) throw new Error("Missing service");
const casesHeading = screen.getByRole("heading", {
level: 2,
name: "Veja projetos parecidos, já prontos",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
).toBe(true);
expect(
isBefore(
casesHeading,
screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
),
).toBe(true);
const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ });
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual(
getRelatedCases(siteService.relatedCaseIds).map(({ id }) => `/cases/${id}`),
);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
"/cases/sc-plus",
"/cases/as-servicos",
]);
// pôster do primeiro case numa moldura no topo, no lugar do fundo verde
const hero = container.querySelector("section");
const heroFrame = hero?.querySelector(".ds-bframe");
expect(heroFrame).not.toBeNull();
expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt",
"Captura de tela do site SEV Moda Íntima",
);
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");
expect(
screen.queryByRole("heading", {
name: "Veja projetos parecidos, já prontos",
}),
).toBeNull();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(
screen.queryByRole("link", { name: "Ver projetos parecidos" }),
).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("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => {
const { container } = await renderService("integracao-hardware-software-iot");
const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ });
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab",
]);
expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible();
// sem captura gravada, não há moldura nem imagem
expect(container.querySelector(".ds-bframe")).toBeNull();
});
it("renders the offer block only when some field is filled in", async () => {
const empty = await renderService("criacao-de-sites");
expect(
screen.queryByRole("heading", { name: "Investimento e prazo" }),
).toBeNull();
empty.unmount();
const offerService = getServicePage("criacao-de-sites");
if (!offerService) throw new Error("Missing service");
offerService.typicalTimeline = "Texto de prazo de teste";
try {
await renderService("criacao-de-sites");
expect(
screen.getByRole("heading", { level: 2, name: "Investimento e prazo" }),
).toBeVisible();
expect(screen.getByText("Prazo típico")).toBeVisible();
expect(screen.getByText("Texto de prazo de teste")).toBeVisible();
expect(screen.queryByText("A partir de")).toBeNull();
expect(screen.queryByText("Garantia")).toBeNull();
} finally {
delete offerService.typicalTimeline;
}
});
it("keeps related case ids resolving to real cases", () => {
for (const id of service.relatedCaseIds) {
expect(caseStudies.some((item) => item.id === id)).toBe(true);
}
});
+55 -157
View File
@@ -1,18 +1,30 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildServiceJsonLd } from "@/lib/seo";
import {
buildBreadcrumbList,
buildPageSocialMetadata,
buildServiceJsonLd,
} from "@/lib/seo";
import {
getRelatedCases,
getServicePage,
servicePages,
siteName,
siteUrl,
} from "@/lib/site-content";
import { ServiceFaq } from "../_components/ServiceFaq";
import { ServiceHeroCover } from "../_components/ServiceHeroCover";
import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
interface ServicePageProps {
params: Promise<{
@@ -41,22 +53,12 @@ export async function generateMetadata({
return {
title: service.seoTitle,
description: service.metaDescription,
alternates: {
canonical: url,
},
openGraph: {
locale: "pt_BR",
title: service.seoTitle,
description: service.metaDescription,
...buildPageSocialMetadata({
url,
siteName,
type: "article",
},
twitter: {
card: "summary_large_image",
title: service.seoTitle,
description: service.metaDescription,
},
type: "article",
}),
};
}
@@ -69,6 +71,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
}
const relatedCases = getRelatedCases(service.relatedCaseIds);
const heroCase = relatedCases.find((caseStudy) =>
getCaseMedia(caseStudy.id, "desktop"),
);
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
const jsonLd = {
@@ -87,35 +92,22 @@ export default async function ServicePage({ params }: ServicePageProps) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<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"
: ""
}`}
>
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/servicos">
Serviços
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
{service.navLabel}
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
{ label: service.navLabel, href: `/servicos/${service.slug}` },
]}
/>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
{service.headline}
@@ -127,18 +119,15 @@ export default async function ServicePage({ params }: ServicePageProps) {
<WhatsAppLink
service={service.navLabel}
placement="service_detail_hero"
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver casos relacionados
</MagneticButton>
{relatedCases.length > 0 ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
Ver projetos parecidos
</a>
) : null}
</div>
<p className="mt-sp-4 text-sm leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
@@ -146,9 +135,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
SC.
</p>
</div>
<ServiceHeroCover caseStudy={heroCase} />
</div>
</section>
<ServiceRelatedCases cases={relatedCases} />
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
@@ -192,8 +184,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-kicker">Possibilidades de uso</div>
<h2 className="ds-card-title">Imagine na sua rotina</h2>
<p className="ds-copy">
Exemplos ilustrativos para ajudar a explicar o serviço, não
projetos já entregues. O seu escopo será definido na conversa.
Situações comuns em que este serviço ajuda. A sua será combinada
na conversa.
</p>
<ul className="ds-list">
{service.examples.map((example) => (
@@ -215,9 +207,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
))}
</ul>
<p className="ds-copy">
Tecnologias consideradas neste serviço. A seleção depende do
problema e do ambiente; não é uma lista obrigatória para todo
projeto.
Tecnologias que costumo usar neste serviço. A escolha depende do
problema e do ambiente.
</p>
<ul className="ds-tags" aria-label="Tecnologias do serviço">
{service.stack.map((item) => (
@@ -231,71 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Casos relacionados</p>
<h2 className="ds-section-title">
Projetos para conhecer meu trabalho
</h2>
<p className="ds-section-description">
Referências de problemas e soluções relacionados. Cada projeto
usa as tecnologias indicadas no próprio case; não representa
todas as plataformas ou tecnologias oferecidas neste serviço.
</p>
</div>
</div>
<div className="ds-grid-2">
{relatedCases.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.subtitle}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<p className="mt-sp-4 text-[13px] leading-7 text-[var(--meta)]">
{caseStudy.impact}
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className="ds-link ds-link-accent"
target="_blank"
rel="noreferrer"
>
Abrir projeto: {caseStudy.title}
</a>
) : null}
{caseStudy.githubUrl ? (
<a
href={caseStudy.githubUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
>
Ver código: {caseStudy.title}
</a>
) : null}
</div>
</article>
))}
</div>
</div>
</section>
<ServiceOffer service={service} />
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell">
@@ -314,48 +241,19 @@ export default async function ServicePage({ params }: ServicePageProps) {
<WhatsAppLink
service={service.navLabel}
placement="service_detail_footer"
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
<Link href="/servicos" className={secondaryButtonDark}>
Ver todos os serviços
</MagneticButton>
</Link>
</div>
</div>
</div>
</section>
{service.faq && service.faq.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de
cada projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{service.faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
) : null}
<ServiceFaq faq={service.faq} />
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ServicePageContent } from "@/lib/site-content";
/** "Perguntas frequentes" do serviço. Sem perguntas, a seção não aparece. */
export function ServiceFaq({ faq }: { faq: ServicePageContent["faq"] }) {
if (!faq || faq.length === 0) return null;
return (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de cada
projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
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. */
export function ServiceHeroCover({
caseStudy,
}: {
caseStudy?: CaseStudyContent;
}) {
if (!caseStudy) return null;
return (
<Link 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"
priority
/>
</Link>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { ServicePageContent } from "@/lib/site-content";
/** "Investimento e prazo": só mostra os campos preenchidos com valor real; sem nenhum, a seção não aparece. */
export function ServiceOffer({
service,
}: {
service: Pick<
ServicePageContent,
"startingPrice" | "typicalTimeline" | "guarantee"
>;
}) {
const offer = [
{ label: "A partir de", value: service.startingPrice },
{ label: "Prazo típico", value: service.typicalTimeline },
{ label: "Garantia", value: service.guarantee },
].filter((item): item is { label: string; value: string } =>
Boolean(item.value),
);
if (offer.length === 0) return null;
return (
<section className="ds-section" aria-labelledby="investimento-prazo">
<div className="ds-section-shell">
<h2 id="investimento-prazo" className="ds-card-title !mt-0">
Investimento e prazo
</h2>
<dl className="mt-sp-6 grid gap-sp-6 md:grid-cols-3">
{offer.map((item) => (
<div
key={item.label}
className="border-t border-[var(--border)] pt-sp-4"
>
<dt className="ds-kicker">{item.label}</dt>
<dd className="ds-copy !mt-sp-2">{item.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
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. */
export function ServiceRelatedCases({
cases,
}: {
cases: readonly CaseStudyContent[];
}) {
if (cases.length === 0) return null;
return (
<section
id="casos-relacionados"
className="ds-section"
aria-labelledby="casos-relacionados-titulo"
>
<div className="ds-section-shell">
<div className="ds-section-header">
<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
</h2>
<p className="ds-section-description">
Projetos reais nesta área. Abra o case para ver o que foi feito e
como ficou.
</p>
</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>
</div>
</section>
);
}
+50 -53
View File
@@ -1,19 +1,23 @@
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 { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
textLink,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import {
caseStudies,
serviceHubLinks,
siteName,
siteUrl,
} from "@/lib/site-content";
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";
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas";
const pageDescription =
"Sites, sistemas web e aplicativos sob medida com Evandro, desenvolvedor em Garuva-SC e atendimento remoto no Brasil. Conheça projetos e converse pelo WhatsApp.";
"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.";
const businessIntents = [
{
@@ -45,24 +49,15 @@ const businessIntents = [
},
];
const featuredCases = caseStudies.filter((caseStudy) =>
["sc-plus", "nutriscan"].includes(caseStudy.id),
);
export const metadata: Metadata = {
title: pageTitle,
description: pageDescription,
alternates: {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
...buildPageSocialMetadata({
url: pageUrl,
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
}),
};
export default function ServicesIndexPage() {
@@ -96,24 +91,14 @@ export default function ServicesIndexPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-3 text-sm">
<li>
<Link
className="inline-flex min-h-11 items-center underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
href="/"
>
Início
</Link>
</li>
<li className="flex items-center gap-sp-3">
<span aria-hidden="true">/</span>
<span aria-current="page">Serviços</span>
</li>
</ol>
</nav>
<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>
@@ -124,7 +109,7 @@ export default function ServicesIndexPage() {
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
<WhatsAppLink
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
service="Sites, sistemas e aplicativos"
placement="service_hub_hero"
>
@@ -132,7 +117,7 @@ export default function ServicesIndexPage() {
</WhatsAppLink>
<a
href="#projetos"
className="ds-link ds-link-muted min-h-12 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver projetos realizados
</a>
@@ -165,7 +150,7 @@ export default function ServicesIndexPage() {
<p className="ds-copy">{intent.description}</p>
<div className="mt-sp-4 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2">
<WhatsAppLink
className="ds-link ds-link-accent min-h-11"
className={primaryButton}
service={intent.service}
placement="service_hub_intent"
>
@@ -173,7 +158,7 @@ export default function ServicesIndexPage() {
</WhatsAppLink>
<Link
href={intent.href}
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{intent.linkLabel}
</Link>
@@ -194,24 +179,36 @@ export default function ServicesIndexPage() {
Veja o trabalho antes de conversar.
</h2>
<p className="ds-copy max-w-[64ch]">
Dois projetos com demonstrações publicadas. Os cases mostram o que
foi implementado e as tecnologias usadas, sem promessas de resultado
comercial.
Projetos feitos para empresas. Abra o case para ver o que foi feito
e como o site ficou.
</p>
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2">
{featuredCases.map((caseStudy) => (
{clientCases.map((caseStudy) => (
<article
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
<p className="mb-sp-3 text-sm text-[var(--meta)]">
Demonstração publicada
</p>
<h3 className="ds-card-title">{caseStudy.title}</h3>
{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}
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<Link
href={`/cases/${caseStudy.id}`}
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${textLink} mt-sp-4 focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver o case {caseStudy.title}
</Link>
@@ -286,7 +283,7 @@ export default function ServicesIndexPage() {
</h2>
<p className="ds-copy max-w-[64ch]">
Consulte as áreas de atuação, incluindo desktop, integrações,
consultoria e IoT. Cada página explica possibilidades e limites.
consultoria e IoT. Cada página explica o que posso fazer por você.
</p>
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service) => (
@@ -318,7 +315,7 @@ export default function ServicesIndexPage() {
formulário para começar.
</p>
<WhatsAppLink
className="ds-link ds-link-accent mt-sp-6 min-h-12"
className={`${primaryButton} mt-sp-6`}
service="Sites, sistemas e aplicativos"
placement="service_hub_footer"
>