140 lines
5.2 KiB
TypeScript
140 lines
5.2 KiB
TypeScript
// app/services/[slug]/page.tsx
|
||
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import JsonLd from "@/components/JsonLd";
|
||
import CtaPanel from "@/components/CtaPanel";
|
||
import { ServiceBullets } from "@/components/ServiceBullets";
|
||
import { getAllServices, getServiceBySlug } from "@/lib/services";
|
||
import { site } from "@/lib/site"; // assumes lib/site.ts exports { site: { url, name, ... } }
|
||
|
||
export const revalidate = 86400;
|
||
|
||
type Params = { slug: string };
|
||
|
||
export async function generateStaticParams() {
|
||
return getAllServices().map((s) => ({ slug: s.slug }));
|
||
}
|
||
|
||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||
const svc = getServiceBySlug(params.slug);
|
||
if (!svc) return {};
|
||
return {
|
||
title: svc.metaTitle,
|
||
description: svc.metaDescription,
|
||
alternates: { canonical: `/services/${svc.slug}` },
|
||
openGraph: {
|
||
title: svc.metaTitle,
|
||
description: svc.metaDescription,
|
||
url: `${site.url}/services/${svc.slug}`,
|
||
type: "article",
|
||
images: [{ url: "/og.png", width: 1200, height: 630, alt: "Van Hunen IT" }],
|
||
},
|
||
twitter: {
|
||
card: "summary_large_image",
|
||
title: svc.metaTitle,
|
||
description: svc.metaDescription,
|
||
images: ["/og.png"],
|
||
},
|
||
};
|
||
}
|
||
|
||
export default function ServicePage({ params }: { params: Params }) {
|
||
const svc = getServiceBySlug(params.slug);
|
||
if (!svc) notFound();
|
||
|
||
const breadcrumbLd = {
|
||
"@context": "https://schema.org",
|
||
"@type": "BreadcrumbList",
|
||
"itemListElement": [
|
||
{ "@type": "ListItem", position: 1, name: "Services", item: `${site.url}/services` },
|
||
{ "@type": "ListItem", position: 2, name: svc.title, item: `${site.url}/services/${svc.slug}` },
|
||
],
|
||
};
|
||
|
||
const svcLd = {
|
||
"@context": "https://schema.org",
|
||
"@type": "Service",
|
||
name: svc.title,
|
||
description: svc.metaDescription || svc.outcome,
|
||
provider: { "@type": "Organization", name: site.name, url: site.url, logo: `${site.url}/og.png` },
|
||
areaServed: "EU",
|
||
offers: { "@type": "Offer", price: svc.price.replace(/[^\d]/g, "") || "0", priceCurrency: "EUR", availability: "https://schema.org/InStock" },
|
||
};
|
||
|
||
const related = (svc.relatedSlugs || [])
|
||
.map((slug) => getAllServices().find((s) => s.slug === slug))
|
||
.filter(Boolean) as NonNullable<ReturnType<typeof getServiceBySlug>>[];
|
||
|
||
return (
|
||
<main className="container mx-auto max-w-3xl px-4 py-10">
|
||
<JsonLd data={breadcrumbLd} />
|
||
<JsonLd data={svcLd} />
|
||
|
||
<header aria-labelledby="service-title">
|
||
<p className="text-sm text-neutral-500">
|
||
<Link className="hover:underline" href="/services">← Services</Link>
|
||
</p>
|
||
<h1 id="service-title" className="mt-2 text-3xl font-bold tracking-tight">{svc.title}</h1>
|
||
<p className="mt-2 text-neutral-700 dark:text-neutral-300">{svc.outcome}</p>
|
||
</header>
|
||
|
||
<section aria-labelledby="who-heading" className="mt-8">
|
||
<h2 id="who-heading" className="text-xl font-semibold tracking-tight">Who it’s for</h2>
|
||
<ul className="mt-3 list-disc pl-6 space-y-1 text-neutral-800 dark:text-neutral-200">
|
||
{svc.who.map((w, i) => <li key={i}>{w}</li>)}
|
||
</ul>
|
||
</section>
|
||
|
||
{svc.outcomes && <ServiceBullets title="Outcomes" items={svc.outcomes} />}
|
||
<ServiceBullets title="Deliverables" items={svc.deliverables} />
|
||
|
||
<section aria-labelledby="timeline-price-heading" className="mt-8 grid gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<h3 id="timeline-price-heading" className="text-xl font-semibold tracking-tight">Timeline</h3>
|
||
<p className="mt-2">{svc.timeline}</p>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-xl font-semibold tracking-tight">Price</h3>
|
||
<p className="mt-2">{svc.price}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<ServiceBullets title="Proof we deliver" items={svc.proof} />
|
||
|
||
{svc.faq?.length ? (
|
||
<section aria-labelledby="faq-heading" className="mt-8">
|
||
<h2 id="faq-heading" className="text-xl font-semibold tracking-tight">FAQ</h2>
|
||
<div className="mt-4 space-y-4">
|
||
{svc.faq.map((f, i) => (
|
||
<details key={i} className="rounded-md border p-4">
|
||
<summary className="cursor-pointer font-medium">{f.q}</summary>
|
||
<p className="mt-2 text-neutral-700 dark:text-neutral-300">{f.a}</p>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{related.length ? (
|
||
<section aria-labelledby="related-heading" className="mt-10">
|
||
<h2 id="related-heading" className="text-xl font-semibold tracking-tight">Related services</h2>
|
||
<ul className="mt-3 space-y-2">
|
||
{related.map((r) => (
|
||
<li key={r.slug}>
|
||
<Link href={`/services/${r.slug}`} className="hover:underline">{r.title}</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null}
|
||
|
||
<CtaPanel
|
||
title="Ready to start?"
|
||
body="Book a free 20-minute check. We’ll confirm scope, risks, and the fastest path to proof."
|
||
href={`/contact?topic=${encodeURIComponent(svc.slug)}`}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|