feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
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:
1 parent
398db5c5ec
commit
6814e9e2c8
25 files changed
+524
-119
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user