feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s

Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero
(home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem
Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden
Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der
Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt,
Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher
nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder
(Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder
(Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt
statt voller Viewport-Breite.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-11 14:53:14 +02:00
1 parent 398db5c5ec
commit 6814e9e2c8
25 files changed
+524 -119

No files matched your search

+6 -1
View File
@@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath } from "@/lib/uploads";
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared";
/**
@@ -43,6 +43,11 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
continue;
}
// Analog zum Bild-Feld: ein Video-Feld darf nur einen Pfad enthalten,
// den app/api/admin/upload-video/route.ts tatsaechlich erzeugt hat.
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
continue;
}
updates.push({ key: def.key, valueDe, valueEn: null });
}
}
+15 -3
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale";
@@ -13,10 +14,11 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-Feld hat kein <textarea>, nutzt ROWS_BY_TYPE also nicht — Wert nur
// vorhanden, damit dieses Record<ContentFieldDef["type"], number> weiter
// vollstaendig ist.
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// weiter vollstaendig ist.
image: 0,
video: 0,
};
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
@@ -77,6 +79,16 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
/>
);
}
if (def.type === "video") {
return (
<AdminVideoField
key={def.key}
name={def.key}
label={label}
initialValue={row?.valueDe ?? ""}
/>
);
}
return (
<label key={def.key} className="block text-sm">
<span className="text-ink/70">{label}</span>
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log";
import {
UPLOAD_DIR,
MAX_UPLOAD_VIDEO_BYTES,
detectVideoExtension,
generateUploadFilename,
} from "@/lib/uploads";
import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge).
export const runtime = "nodejs";
/**
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, ein
* Video wird deshalb NICHT neu enkodiert (kein Pixel-fuer-Pixel-Rebuild wie
* bei Bildern moeglich), sondern nur anhand seiner Magic-Bytes als MP4/WebM
* verifiziert und unveraendert gespeichert. Das ist vertretbar, weil (1) nur
* eingeloggte 2FA-Admins diesen Endpunkt erreichen (requireAdminForApi),
* (2) ausgelieferte Dateien einen festen, aus der Endung abgeleiteten
* Content-Type + "X-Content-Type-Options: nosniff" bekommen (siehe
* app/uploads/[...path]/route.ts) und daher nie als HTML/Skript interpretiert
* werden koennen, und (3) der zufaellige Dateiname + die feste Endungs-
* Allowlist jede andere Nutzung des Pfads verhindern.
*/
export async function POST(request: Request) {
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const auth = await requireAdminForApi();
if ("response" in auth) return auth.response;
const { admin } = auth;
// Videos sind ein seltener redaktioneller Vorgang (ein Hero-Video pro
// Startseite) — deutlich niedrigeres Limit als der Bild-Upload reicht.
if (!rateLimit(`admin-upload-video:${admin.id}`, 10, 60 * 60 * 1000)) {
return NextResponse.json(
{ error: "Zu viele Uploads. Bitte versuche es später erneut." },
{ status: 429 }
);
}
const formData = await request.formData().catch(() => null);
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Keine Datei übermittelt." }, { status: 400 });
}
if (file.size === 0) {
return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 });
}
if (file.size > MAX_UPLOAD_VIDEO_BYTES) {
return NextResponse.json(
{ error: `Die Datei ist zu groß (max. ${Math.floor(MAX_UPLOAD_VIDEO_BYTES / (1024 * 1024))} MB).` },
{ status: 413 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const extension = detectVideoExtension(buffer);
if (!extension) {
return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 }
);
}
await mkdir(UPLOAD_DIR, { recursive: true });
const filename = generateUploadFilename(extension);
await writeFile(path.join(UPLOAD_DIR, filename), buffer);
await logAdminAction(admin, {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `${extension}, ${Math.ceil(buffer.length / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
}
+38 -27
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Container } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { TrustBadges } from "@/components/trust-badges";
import { ShieldIcon } from "@/components/account-ui";
import { ProductTeaserLink } from "@/components/product-card";
@@ -42,33 +43,41 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
return (
<div className="py-20">
<Container className="max-w-3xl">
<div className="flex items-center gap-2 text-honey-dark">
<ShieldIcon />
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
</div>
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
{batch.harvest.type} {batch.harvest.year}
</h1>
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
<ScrollReveal>
<div className="flex items-center gap-2 text-honey-dark">
<ShieldIcon />
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
</div>
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
{batch.harvest.type} {batch.harvest.year}
</h1>
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
</ScrollReveal>
<div className="mt-10 grid sm:grid-cols-2 gap-6">
<ContentImage
src={batch.harvest.images[0] ?? null}
alt={`${batch.harvest.type} ${batch.harvest.year}`}
tone="honey"
className="aspect-square"
label={`${batch.harvest.type} ${batch.harvest.year}`}
/>
<ContentImage
src={batch.harvest.images[1] ?? null}
alt={batch.harvest.location}
tone="forest"
className="aspect-square"
label={batch.harvest.location}
/>
<ScrollReveal>
<ContentImage
src={batch.harvest.images[0] ?? null}
alt={`${batch.harvest.type} ${batch.harvest.year}`}
tone="honey"
className="aspect-square"
label={`${batch.harvest.type} ${batch.harvest.year}`}
sizes="(min-width: 640px) 50vw, 100vw"
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<ContentImage
src={batch.harvest.images[1] ?? null}
alt={batch.harvest.location}
tone="forest"
className="aspect-square"
label={batch.harvest.location}
sizes="(min-width: 640px) 50vw, 100vw"
/>
</ScrollReveal>
</div>
<div className="mt-14">
<ScrollReveal className="mt-14">
<div className="grid grid-cols-3 gap-4">
{steps.map((step, i) => (
<div key={step.label} className="relative">
@@ -83,19 +92,21 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
</div>
))}
</div>
</div>
</ScrollReveal>
<div className="mt-10 border-t border-ink/10 pt-8">
<ScrollReveal className="mt-10 border-t border-ink/10 pt-8">
<p className="text-sm tracking-wide-caps text-ink/70 mb-2">{t.chargePage.regionLabel}</p>
<p className="text-ink/80">{batch.harvest.location}</p>
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p>
</div>
</ScrollReveal>
<TrustBadges className="mt-10 pt-8 border-t border-ink/10" />
{batch.products.length > 0 ? (
<div className="mt-14 border-t border-ink/10 pt-10">
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.chargePage.fromThisBatch}</p>
<ScrollReveal>
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.chargePage.fromThisBatch}</p>
</ScrollReveal>
<div className="grid sm:grid-cols-2 gap-6">
{batch.products.map((product) => (
<ProductTeaserLink
+13 -9
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { Container } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { BackLink } from "@/components/back-link";
import { ProductTeaserLink } from "@/components/product-card";
import { getHarvestByYearType } from "@/lib/data";
@@ -45,14 +46,17 @@ export default async function HarvestDetailPage(
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
</Container>
<Container className="grid md:grid-cols-2 gap-14">
<ContentImage
src={harvest.images[0] ?? null}
alt={harvest.type}
tone="forest"
className="aspect-[4/5] w-full"
label={harvest.location}
/>
<div>
<ScrollReveal>
<ContentImage
src={harvest.images[0] ?? null}
alt={harvest.type}
tone="forest"
className="aspect-[4/5] w-full"
label={harvest.location}
sizes="(min-width: 768px) 50vw, 100vw"
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
{harvest.year} · {formatDate(harvest.harvestDate, locale)}
</p>
@@ -86,7 +90,7 @@ export default async function HarvestDetailPage(
/>
))}
</div>
</div>
</ScrollReveal>
</Container>
</div>
);
+25 -18
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getHarvests } from "@/lib/data";
import { formatMonthYear } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
@@ -29,29 +30,35 @@ export default async function ErntenPage() {
return (
<div className="py-20">
<Container>
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
<ScrollReveal>
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
</ScrollReveal>
<div className="mt-16 space-y-16">
{years.map((year) => (
<div key={year}>
<h2 className="text-2xl text-forest mb-6">{year}</h2>
<ScrollReveal>
<h2 className="text-2xl text-forest mb-6">{year}</h2>
</ScrollReveal>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
{byYear[year].map((harvest) => (
<Link
key={harvest.id}
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
className="group"
>
<ContentImage
src={harvest.images[0] ?? null}
alt={harvest.type}
tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
label={formatMonthYear(harvest.harvestDate, locale)}
/>
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
</Link>
{byYear[year].map((harvest, i) => (
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
<Link
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
className="group block"
>
<ContentImage
src={harvest.images[0] ?? null}
alt={harvest.type}
tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
label={formatMonthYear(harvest.harvestDate, locale)}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
</Link>
</ScrollReveal>
))}
</div>
</div>
+13 -9
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValue } from "@/lib/content";
@@ -26,20 +27,23 @@ export default async function HerkunftPage() {
tone="forest"
className="h-[50vh] min-h-[360px] w-full mb-20"
label={t.herkunftPage.eyebrow}
priority
/>
<Container>
<SectionHeading
level="h1"
eyebrow={t.herkunftPage.eyebrow}
title={t.herkunftPage.title}
subtitle={t.herkunftPage.subtitle}
/>
<ScrollReveal>
<SectionHeading
level="h1"
eyebrow={t.herkunftPage.eyebrow}
title={t.herkunftPage.title}
subtitle={t.herkunftPage.subtitle}
/>
</ScrollReveal>
<div className="mt-16 grid sm:grid-cols-2 gap-10">
{t.herkunftPage.aspects.map((a) => (
<div key={a.title}>
{t.herkunftPage.aspects.map((a, i) => (
<ScrollReveal key={a.title} delay={i * 0.08}>
<h3 className="text-lg text-forest">{a.title}</h3>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
</div>
</ScrollReveal>
))}
</div>
<p className="mt-16 text-xs text-ink/65 max-w-md">
+7 -2
View File
@@ -3,6 +3,7 @@ import { Fragment } from "react";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Container, Button } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { ProductGallery } from "@/components/product-gallery";
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
import { RecordProductView } from "@/components/record-product-view";
@@ -324,7 +325,9 @@ export default async function ProductDetailPage(
</Container>
<Container id="bewertungen" className="mt-20 max-w-3xl">
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
<ScrollReveal>
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
</ScrollReveal>
{error ? (
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
@@ -404,7 +407,9 @@ export default async function ProductDetailPage(
{relatedProducts.length > 0 ? (
<Container className="mt-20">
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
<ScrollReveal>
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{relatedProducts.map((related) => (
<ProductCard key={related.id} product={related} />
+13 -9
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValue } from "@/lib/content";
@@ -21,7 +22,7 @@ export default async function ImkereiPage() {
return (
<div className="py-20">
<Container className="grid md:grid-cols-2 gap-14 items-center">
<div>
<ScrollReveal>
<SectionHeading
level="h1"
eyebrow={t.imkereiPage.eyebrow}
@@ -32,14 +33,17 @@ export default async function ImkereiPage() {
<p key={paragraph}>{paragraph}</p>
))}
</div>
</div>
<ContentImage
src={image || null}
alt={t.imkereiPage.imageLabel}
tone="honey"
className="aspect-[4/5] w-full"
label={t.imkereiPage.imageLabel}
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<ContentImage
src={image || null}
alt={t.imkereiPage.imageLabel}
tone="honey"
className="aspect-[4/5] w-full"
label={t.imkereiPage.imageLabel}
sizes="(min-width: 768px) 50vw, 100vw"
/>
</ScrollReveal>
</Container>
</div>
);
+8 -5
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getCompanyInfo } from "@/lib/company-info";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -19,9 +20,11 @@ export default async function KontaktPage() {
return (
<div className="py-20">
<Container className="max-w-2xl">
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
<ScrollReveal>
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
</ScrollReveal>
<div className="mt-10 space-y-2 text-ink/75">
<ScrollReveal delay={0.1} className="mt-10 space-y-2 text-ink/75">
<p>{company.name}</p>
<p>{company.address.street}</p>
<p>
@@ -33,14 +36,14 @@ export default async function KontaktPage() {
</a>
</p>
<p>{company.phone}</p>
</div>
</ScrollReveal>
<div className="mt-12 border-t border-ink/10 pt-8">
<ScrollReveal delay={0.18} className="mt-12 border-t border-ink/10 pt-8">
<h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
{t.kontaktPage.pickupText}
</p>
</div>
</ScrollReveal>
</Container>
</div>
);
+5 -1
View File
@@ -29,7 +29,10 @@ export const dynamic = "force-dynamic";
export default async function HomePage() {
const locale = await getLocale();
const [heroContent, company] = await Promise.all([
getContentValues(["home.hero.intro", "home.hero.footnote", "home.hero.image"], locale),
getContentValues(
["home.hero.intro", "home.hero.footnote", "home.hero.image", "home.hero.video"],
locale
),
getCompanyInfo(),
]);
const products = await getActiveProducts();
@@ -49,6 +52,7 @@ export default async function HomePage() {
footnote={heroContent["home.hero.footnote"]}
location={company.location}
image={heroContent["home.hero.image"] || null}
video={heroContent["home.hero.video"] || null}
/>
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
<OriginStory />
+9 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { ProductCard } from "@/components/product-card";
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
import { getActiveProducts, type ProductSort } from "@/lib/data";
@@ -82,12 +83,14 @@ export default async function ShopPage(props: PageProps<"/shop">) {
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
/>
<Container>
<SectionHeading
level="h1"
eyebrow={t.shop.eyebrow}
title={t.shop.title}
subtitle={t.shop.subtitle}
/>
<ScrollReveal>
<SectionHeading
level="h1"
eyebrow={t.shop.eyebrow}
title={t.shop.title}
subtitle={t.shop.subtitle}
/>
</ScrollReveal>
<div className="mt-8 flex flex-wrap gap-3">
{categoryFilters.map((filter) => {
+15 -11
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { BusinessInquiryForm } from "@/components/business-inquiry-form";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValue } from "@/lib/content";
@@ -27,28 +28,31 @@ export default async function UnternehmenPage() {
tone="honey"
className="h-[45vh] min-h-[320px] w-full"
label={t.unternehmenPage.eyebrow}
priority
/>
<Container className="py-20">
<SectionHeading
level="h1"
eyebrow={t.unternehmenPage.eyebrow}
title={t.unternehmenPage.title}
subtitle={t.unternehmenPage.subtitle}
/>
<ScrollReveal>
<SectionHeading
level="h1"
eyebrow={t.unternehmenPage.eyebrow}
title={t.unternehmenPage.title}
subtitle={t.unternehmenPage.subtitle}
/>
</ScrollReveal>
<div className="mt-14 grid sm:grid-cols-3 gap-8">
{t.unternehmenPage.packages.map((pkg) => (
<div key={pkg.key} className="border border-ink/10 p-8">
{t.unternehmenPage.packages.map((pkg, i) => (
<ScrollReveal key={pkg.key} delay={i * 0.08} className="border border-ink/10 p-8">
<h3 className="text-lg text-forest">{pkg.title}</h3>
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
</div>
</ScrollReveal>
))}
</div>
<div className="mt-20 max-w-2xl">
<ScrollReveal className="mt-20 max-w-2xl">
<h2 className="text-2xl text-forest mb-8">{t.unternehmenPage.inquiryHeading}</h2>
<BusinessInquiryForm />
</div>
</ScrollReveal>
</Container>
</div>
);
+11 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { wissenSlugs } from "@/lib/site-config";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -19,15 +20,19 @@ export default async function WissenPage() {
return (
<div className="py-20">
<Container className="max-w-3xl">
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} />
<ScrollReveal>
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} />
</ScrollReveal>
<div className="mt-14 space-y-10">
{wissenSlugs.map((slug) => {
{wissenSlugs.map((slug, i) => {
const article = t.wissenArticles[slug];
return (
<Link key={slug} href={`/wissen/${slug}`} className="block group">
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
</Link>
<ScrollReveal key={slug} delay={(i % 5) * 0.06}>
<Link href={`/wissen/${slug}`} className="block group">
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
</Link>
</ScrollReveal>
);
})}
</div>