feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,369 @@
|
||||
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 { 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 { 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 { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
// 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 t = getDictionary(await getLocale());
|
||||
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;
|
||||
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] = 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(),
|
||||
]);
|
||||
|
||||
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],
|
||||
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">
|
||||
<RecordProductView productId={product.id} />
|
||||
<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} />
|
||||
|
||||
<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)}
|
||||
</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)}
|
||||
</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)} />
|
||||
<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 && product.batch ? (
|
||||
<Link
|
||||
href={`/charge/${product.batch.code}`}
|
||||
className="mt-6 inline-block text-sm text-forest underline"
|
||||
>
|
||||
{t.productPage.viewBatchOrigin}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
|
||||
{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)}
|
||||
</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">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user