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:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
"use client";
import Link from "next/link";
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges";
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
import { QuantityStepper } from "@/components/quantity-stepper";
import { ProductCard } from "@/components/product-card";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
const CROSS_SELL_LIMIT = 3;
export function WarenkorbContent({
tiers,
availableProducts,
bulkDiscountTiers,
}: {
tiers: ShippingTierLike[];
availableProducts: ProductWithRelations[];
bulkDiscountTiers: BulkDiscountTierLike[];
}) {
const { t } = useI18n();
const items = useCartStore((s) => s.items);
const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem);
const subtotal = cartTotalCents(items);
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
const totalQuantity = cartItemCount(items);
const shipping = items.length === 0 ? 0 : resolveShippingCostCents(tiers, totalQuantity);
const nextTier = items.length === 0 ? null : getNextShippingTier(tiers, totalQuantity);
// Live-Vorschau des serverseitig in api/checkout/route.ts automatisch
// angewendeten Mengenrabatts — Hoehe/Menge kommen aus den im Admin-Bereich
// (Gutscheine) gepflegten DiscountCode-Zeilen, nicht mehr aus Konstanten.
const activeBulkDiscount = items.length === 0 ? null : resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
const bulkDiscountCents = activeBulkDiscount
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
: 0;
const nextBulkDiscount = items.length === 0 ? null : getNextBulkDiscountTier(bulkDiscountTiers, totalQuantity);
const cartProductIds = new Set(items.map((item) => item.productId));
const crossSellProducts = availableProducts
.filter((product) => !cartProductIds.has(product.id))
.slice(0, CROSS_SELL_LIMIT);
return (
<div className="py-16">
<Container>
<SectionHeading eyebrow={t.cart.title} title={t.cartPage.title} />
{items.length === 0 ? (
<div className="mt-14">
<p className="text-ink/70">{t.cart.empty}</p>
<LinkButton href="/shop" className="mt-6">
{t.cartPage.goToShop}
</LinkButton>
</div>
) : (
<div className="mt-14 grid lg:grid-cols-3 gap-14">
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
{items.map((item) => (
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
<div className="flex-1 min-w-[160px]">
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
{item.name}
</Link>
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
</div>
<div className="flex items-center gap-4 ml-auto">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
/>
<span className="w-20 text-right text-ink">
{formatPrice(item.priceCents * item.quantity)}
</span>
<button
onClick={() => removeItem(item.productId)}
aria-label={t.cart.remove}
className="text-ink/65 hover:text-ink"
>
✕
</button>
</div>
</div>
))}
</div>
<div className="border border-ink/10 p-8 h-fit">
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between">
<span>{t.cartPage.shippingLabel(totalQuantity)}</span>
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
</div>
{nextTier && nextTier.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60">
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents))}
</p>
) : null}
{activeBulkDiscount ? (
<div className="flex justify-between text-forest">
<span>
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
</span>
<span>-{formatPrice(bulkDiscountCents)}</span>
</div>
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60">
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
</p>
) : null}
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
<span>{t.cartPage.total}</span>
<span>{formatPrice(subtotal - bulkDiscountCents + shipping)}</span>
</div>
</div>
<LinkButton href="/checkout" className="w-full mt-6">
{t.cart.checkout}
</LinkButton>
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/60 hover:text-forest underline">
{t.cartPage.pickupHint}
</Link>
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
</div>
</div>
)}
{items.length > 0 && crossSellProducts.length > 0 ? (
<div 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">
{crossSellProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
) : null}
</Container>
</div>
);
}