Files
Wabenhain/src/app/honig/[slug]/page.tsx
T
Nick SzaunigandClaude Sonnet 5 6814e9e2c8
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
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]>
2026-09-11 14:53:14 +02:00

436 lines
19 KiB
TypeScript

import type { Metadata } from "next";
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";
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
import { StarRating } from "@/components/star-rating";
import { TrustBadges } from "@/components/trust-badges";
import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar";
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
import { siteConfig } from "@/lib/site-config";
import { getCompanyInfo } from "@/lib/company-info";
import { getCurrentCustomer } from "@/lib/auth";
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
import { isProductWishlisted } from "@/lib/wishlist";
import { getShippingTiers } from "@/lib/shipping";
import { resolveShippingCostCents } from "@/lib/shipping-calc";
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { WissenSlug } from "@/i18n/dictionary-type";
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
// von einer zur Build-Zeit erreichbaren Datenbank.
export const dynamic = "force-dynamic";
export async function generateMetadata(
props: PageProps<"/honig/[slug]">
): Promise<Metadata> {
const { slug } = await props.params;
const product = await getProductBySlug(slug);
if (!product) return {};
return {
title: product.name,
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
alternates: { canonical: `/honig/${slug}` },
};
}
export default async function ProductDetailPage(
props: PageProps<"/honig/[slug]">
) {
const locale = await getLocale();
const t = getDictionary(locale);
const { slug } = await props.params;
const { error, message, notifyError, notifyMessage } = await props.searchParams;
const product = await getProductBySlug(slug);
if (!product) notFound();
const isHoney = product.category === "HONIG" && product.harvest && product.batch;
// Verlinkt die Ernteart auf den passenden Honigwissen-Artikel, sofern der
// Ernte-Typ (freier Admin-Text, siehe Harvest.type) eindeutig als Frueh-
// oder Sommertracht erkennbar ist — sonst einfach kein zusaetzlicher Link,
// statt eine falsche Zuordnung zu raten.
const harvestTypeLower = product.harvest?.type.toLowerCase() ?? "";
const harvestWissenSlug: WissenSlug | null = harvestTypeLower.includes("früh")
? "was-bedeutet-fruehtracht"
: harvestTypeLower.includes("sommer")
? "was-bedeutet-sommertracht"
: null;
const stock = product.inventory?.quantity ?? 0;
const inStock = stock > 0;
const lowStock = inStock && stock <= 5;
const customer = await getCurrentCustomer();
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
await Promise.all([
getApprovedReviews(product.id),
getReviewSummary(product.id),
getRelatedProducts(product.id, product.category),
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
getCompanyInfo(),
getShippingTiers(),
]);
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
// haengt bei einem kleinen Versandhandel oft direkt daran).
const baseShippingCents = resolveShippingCostCents(shippingTiers, 1);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
sku: product.batch?.code ?? product.slug,
brand: { "@type": "Brand", name: company.name },
category: t.common.categories[product.category],
// Nur bei echter Produktfotografie gesetzt (siehe ProductGallery) — ohne
// hochgeladene Bilder faellt die Galerie auf reine CSS-Platzhalterkacheln
// zurueck, die als "image" im strukturierten Datensatz falsche Tatsachen
// vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute
// URL zu einem echten Produktfoto, keine Platzhaltergrafik).
...(product.images.length > 0
? { image: product.images.map((src) => `https://${siteConfig.domain}${src}`) }
: {}),
offers: {
"@type": "Offer",
priceCurrency: product.currency,
price: (product.priceCents / 100).toFixed(2),
availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
url: `https://wabenhain.com/honig/${product.slug}`,
},
...(reviewSummary.count > 0
? {
aggregateRating: {
"@type": "AggregateRating",
ratingValue: reviewSummary.average?.toFixed(1),
reviewCount: reviewSummary.count,
},
}
: {}),
};
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Shop", item: `https://${siteConfig.domain}/shop` },
{
"@type": "ListItem",
position: 2,
name: t.common.categories[product.category],
item: `https://${siteConfig.domain}/shop?kategorie=${product.category}`,
},
{
"@type": "ListItem",
position: 3,
name: product.name,
item: `https://${siteConfig.domain}/honig/${product.slug}`,
},
],
};
return (
<div className="py-16 pb-28 md:pb-16">
<RecordProductView productId={product.id} />
<StickyAddToCartBar product={product} inStock={inStock} />
<script
type="application/ld+json"
// "<" escapen, damit Produkttexte niemals ein schliessendes
// </script> einschleusen koennen (XSS-Schutz fuer JSON-LD).
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
/>
<Container className="grid md:grid-cols-2 gap-14">
<ProductGallery
images={product.images}
productName={product.name}
description={product.tasteTags.join(", ")}
/>
<div>
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
{company.name} · {t.common.categories[product.category]}
</p>
<h1 className="text-4xl text-forest">{product.name}</h1>
<p className="mt-2 text-ink/70">{product.tasteTags.join(" · ")}</p>
{reviewSummary.count > 0 ? (
<Link href="#bewertungen" className="mt-4 flex items-center gap-2 text-sm">
<StarRating value={reviewSummary.average ?? 0} size={16} />
<span className="text-ink/65">
{reviewSummary.average?.toFixed(1)} ({t.productPage.reviewsLink(reviewSummary.count)})
</span>
</Link>
) : (
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviews}</p>
)}
<div className="mt-6 flex items-baseline gap-3">
<span className="text-2xl text-forest">
{formatPrice(product.priceCents, product.currency, locale)}
</span>
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
</div>
<p className="text-xs text-ink/65">
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
</p>
<Link
href="/versand-zahlung"
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
>
{t.productPage.shippingHint(
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
)}
</Link>
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
{isHoney ? (
<div className="mt-8 space-y-3">
<TasteScale label={t.product.tasteMild} value={product.intensityMild} />
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
</div>
) : null}
{inStock ? (
<>
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
{lowStock ? (
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
) : null}
</>
) : (
<>
<Button disabled className="mt-8 max-w-xs w-full">
{t.productPage.soldOut}
</Button>
<p className="mt-2 text-xs text-ink/65">
{t.productPage.currentlyUnavailable}
</p>
<form action={subscribeStockNotificationAction} className="mt-4 max-w-xs">
<input type="hidden" name="productId" value={product.id} />
<label htmlFor="notify-email" className="text-xs text-ink/65">
{t.productPage.notifyMeLabel}
</label>
<div className="mt-2 flex gap-2">
<input
id="notify-email"
name="email"
type="email"
required
placeholder={t.productPage.emailPlaceholder}
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
<button
type="submit"
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest"
>
{t.productPage.notifySubmit}
</button>
</div>
</form>
{notifyError ? (
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
) : null}
{notifyMessage ? (
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
) : null}
</>
)}
<TrustBadges className="mt-6" />
{customer ? (
<form action={toggleWishlistAction} className="mt-3">
<input type="hidden" name="productId" value={product.id} />
<input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} />
<button type="submit" className="text-sm text-ink/70 hover:text-forest underline">
{isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist}
</button>
</form>
) : (
<p className="mt-3 text-sm text-ink/65">
<Link href="/konto" className="text-forest underline">
{t.productPage.loginLinkLabel}
</Link>
, {t.productPage.loginToSave}
</p>
)}
<ul className="mt-8 space-y-2 text-sm text-ink/70">
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
<li key={item} className="flex items-center gap-2">
<span className="text-honey-dark">✓</span> {item}
</li>
))}
</ul>
<div className="mt-12 border-t border-ink/10 divide-y divide-ink/10 text-sm">
<DetailRow label={t.productPage.detailLabels.description} value={product.description} />
{isHoney && product.harvest && product.batch ? (
<>
<DetailRow
label={t.productPage.detailLabels.origin}
value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`}
/>
<DetailRow
label={t.productPage.detailLabels.harvest}
value={formatDate(product.harvest.harvestDate, locale)}
/>
<DetailRow label={t.productPage.detailLabels.batch} value={product.batch.code} />
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
</>
) : (
<DetailRow label={t.productPage.detailLabels.origin} value={t.productPage.nonHoneyOriginText} />
)}
</div>
{isHoney ? (
<div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span className="text-ink/65">{t.productPage.learnMoreLabel}</span>
<Link href="/wissen/warum-kristallisiert-honig" className="text-forest underline">
{t.wissenArticles["warum-kristallisiert-honig"].title}
</Link>
{harvestWissenSlug ? (
<Link href={`/wissen/${harvestWissenSlug}`} className="text-forest underline">
{t.wissenArticles[harvestWissenSlug].title}
</Link>
) : null}
</div>
) : null}
{isHoney && product.batch ? (
<Link
href={`/charge/${product.batch.code}`}
className="mt-3 inline-block text-sm text-forest underline"
>
{t.productPage.viewBatchOrigin}
</Link>
) : null}
</div>
</Container>
<Container id="bewertungen" className="mt-20 max-w-3xl">
<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>
) : null}
{message ? (
<p className="mt-4 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{reviews.length === 0 ? (
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
) : (
<ul className="mt-6 space-y-6 divide-y divide-ink/10">
{reviews.map((review) => (
<li key={review.id} className="pt-6 first:pt-0">
<StarRating value={review.rating} />
<span className="sr-only">{t.productPage.starsOutOf5(review.rating)}</span>
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
<p className="mt-2 text-xs text-ink/65">
{review.customer.firstName} · {formatDate(review.createdAt, locale)}
</p>
</li>
))}
</ul>
)}
<div className="mt-10 border-t border-ink/10 pt-8">
{!customer ? (
<p className="text-sm text-ink/70">
<Link href="/konto" className="text-forest underline">
{t.productPage.loginLinkLabel}
</Link>
, {t.productPage.loginToReview}
</p>
) : ownReview ? (
<p className="text-sm text-ink/70">
{!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed}
</p>
) : !canReview ? (
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
) : (
<form action={submitReviewAction} className="space-y-4 max-w-sm">
<input type="hidden" name="productId" value={product.id} />
<h3 className="text-sm text-ink/70">{t.productPage.yourRating}</h3>
<div className="flex flex-row-reverse gap-2" role="radiogroup" aria-label="Sternebewertung">
{[5, 4, 3, 2, 1].map((value) => (
<Fragment key={value}>
<input
type="radio"
id={`rating-${value}`}
name="rating"
value={value}
required
defaultChecked={value === 5}
aria-label={t.productPage.starLabel(value)}
className="peer sr-only"
/>
<label
htmlFor={`rating-${value}`}
className="cursor-pointer text-2xl text-honey-dark/30 peer-checked:text-honey-dark"
>
★
</label>
</Fragment>
))}
</div>
<textarea
name="comment"
rows={3}
placeholder={t.productPage.commentPlaceholder}
className="w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
/>
<Button type="submit">{t.productPage.submitReview}</Button>
</form>
)}
</div>
</Container>
{relatedProducts.length > 0 ? (
<Container className="mt-20">
<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} />
))}
</div>
</Container>
) : null}
<Container>
<RecentlyViewedSection excludeProductId={product.id} />
</Container>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="py-4">
<p className="text-ink/65 text-xs tracking-wide-caps mb-1">{label}</p>
<p className="text-ink/80 leading-relaxed">{value}</p>
</div>
);
}