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]>
436 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|