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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user