open-productive-stack/drupal/nextjs/components/home-services.tsx
rnsrk f8b8f53d54 Add Drupal headless stack with Next.js frontend
- Add Next.js frontend service (nextjs) with Dockerfile and source
- Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service
- Add docker-compose.override.yml.disabled for dev hot-reload
- Add install-headless-modules.sh for OAuth/JSON:API module setup
- Add README.md with full setup and configuration guide
- Update nginx/Dockerfile and nginx.conf.template for cms. subdomain
- Update drupal/Dockerfile PHP-FPM build args
- Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
2026-03-30 11:14:17 +02:00

146 lines
4.6 KiB
TypeScript

import { Book, Brain, Calendar, Cpu, Code2, Database, Rocket, Unplug, Wrench } from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { drupal } from "@/lib/drupal"
import type { DrupalServiceNode } from "@/lib/types"
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
const ICON_MAP: Record<string, LucideIcon> = {
coordination: Calendar,
data_processing: Cpu,
deployment: Rocket,
development: Code2,
documentation: Book,
interface_api: Unplug,
interface_and_api: Unplug,
maintainance: Wrench,
maintenance: Wrench,
modelling: Database,
ai: Brain,
}
function toIconKey(type: string): string {
return type.toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_")
}
function getIcon(serviceType: string | undefined): LucideIcon {
if (!serviceType) return Database
const key = toIconKey(serviceType)
return ICON_MAP[key] ?? Database
}
function stripHtml(html: string | undefined): string {
if (!html) return ""
return html.replace(/<[^>]*>/g, "").trim()
}
async function getServices(): Promise<
{ label: string; body: string; icon: LucideIcon }[]
> {
if (!drupalBaseUrl) return []
try {
let raw: { data?: DrupalServiceNode[] } | null = null
try {
raw = await drupal.getResourceCollection<{
data: DrupalServiceNode[]
}>("node--service", {
params: {
"filter[status]": "1",
sort: "created",
},
deserialize: false,
next: { revalidate: 60 },
})
} catch (firstError) {
const msg = (firstError as Error).message ?? ""
if (msg.includes("Unauthorized")) {
await new Promise((r) => setTimeout(r, 1000))
raw = await drupal.getResourceCollection<{
data: DrupalServiceNode[]
}>("node--service", {
params: {
"filter[status]": "1",
sort: "created",
},
deserialize: false,
next: { revalidate: 60 },
})
} else {
throw firstError
}
}
const nodes = raw?.data ?? []
if (!nodes.length) return []
return nodes.map((node) => {
const bodyObj = node.body
const bodyText =
typeof bodyObj === "string"
? stripHtml(bodyObj)
: stripHtml(bodyObj?.value ?? bodyObj?.processed)
return {
body: bodyText,
icon: getIcon(node.field__service__type),
label: node.title ?? "",
}
})
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.warn(
"[HomeServices] CMS unreachable:",
(error as Error).message
)
}
return []
}
}
export async function HomeServices() {
const services = await getServices()
return (
<section className="home-services py-10" aria-labelledby="services-heading">
<div className="home-services-header mb-6 text-center">
<h2
id="services-heading"
className="home-services-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}
>
Services
</h2>
<p className="home-services-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
Data engineering, development, deployment, and ongoing support for
your projects.
</p>
</div>
<div className="home-services-grid mx-2 flex flex-col gap-5 sm:mx-4 lg:mx-6">
{services.map(({ label, body, icon: Icon }) => (
<ScrollRevealCard key={label}>
<div
className="home-services-card mx-auto flex max-w-[1000px] min-h-0 w-full flex-col items-center justify-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-5 py-5 transition-colors hover:border-emerald-200 hover:bg-emerald-50/50 sm:min-h-[7rem] sm:flex-row sm:gap-10 sm:px-10 sm:py-8"
>
<Icon
className="home-services-card-icon size-8 shrink-0 text-emerald-600 sm:size-10 lg:size-12"
aria-hidden
/>
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 text-center sm:gap-2">
<span className="home-services-card-label text-lg font-semibold text-slate-800 sm:text-xl lg:text-2xl">
{label}
</span>
{body && (
<span className="home-services-card-body text-sm leading-snug text-slate-600 sm:text-base lg:text-lg">
{body}
</span>
)}
</div>
</div>
</ScrollRevealCard>
))}
</div>
</section>
)
}