Versand
diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx
index b32dcc8..dddd8bd 100644
--- a/app/cart/components/CartContent.tsx
+++ b/app/cart/components/CartContent.tsx
@@ -5,6 +5,8 @@ import Link from "next/link";
import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
+import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
+import { computeSubtotal, computeCartTotals } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
@@ -35,6 +37,10 @@ export function CartContent({
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
const products = useProducts();
+ const discount = useDiscount();
+ const [discountInput, setDiscountInput] = useState("");
+ const [discountError, setDiscountError] = useState(null);
+ const [discountLoading, setDiscountLoading] = useState(false);
// While the /api/products fetch is still pending, treat a non-empty
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
// lookup was synchronous, so this distinction didn't exist before;
@@ -46,16 +52,37 @@ export function CartContent({
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
- const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
- const totalSavings = items.reduce((sum, { entry, product }) => {
- const discount = discountPercent(product.price, product.compareAtPrice);
- return discount !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
- }, 0);
+ const subtotal = computeSubtotal(items);
const shipping =
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
? 0
: shippingCost;
- const total = subtotal + shipping;
+ const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
+
+ async function handleApplyDiscount() {
+ const code = discountInput.trim();
+ if (!code) return;
+ setDiscountLoading(true);
+ setDiscountError(null);
+ try {
+ const res = await fetch("/api/discount/validate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ code, subtotal }),
+ });
+ const data = await res.json();
+ if (data.valid) {
+ applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
+ setDiscountInput("");
+ } else {
+ setDiscountError(data.reason || "Dieser Code ist ungültig.");
+ }
+ } catch {
+ setDiscountError("Rabattcode konnte gerade nicht geprüft werden.");
+ } finally {
+ setDiscountLoading(false);
+ }
+ }
return (
<>
@@ -206,6 +233,49 @@ export function CartContent({
)}
+ {/* Rabattcode — cart-only, /checkout only displays the
+ already-applied result (see lib/discount.ts, shared via
+ localStorage the same way the cart itself is). */}
+ {discount ? (
+
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx
index 61b5221..0904ef4 100644
--- a/app/cart/components/RelatedProducts.tsx
+++ b/app/cart/components/RelatedProducts.tsx
@@ -16,25 +16,17 @@ function pickRandom(allIds: string[], excludeIds: string[], count: number): stri
return shuffled.slice(0, count);
}
-// The catalog only has a handful of products — once the cart holds enough
-// distinct ones, "N recommendations that aren't already in the cart" can
-// become impossible (e.g. 4 products total, 2 already in the cart, but
-// DISPLAY_COUNT is 3 — only 2 non-cart products exist, period). Falling
-// back to re-suggesting something already in the cart (a normal "grab
-// another one" pattern) beats silently shrinking the grid below
-// DISPLAY_COUNT. Shared by both the initial pick and the swap-after-add
-// path so neither can under-fill the grid.
-function pickWithFallback(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
+// Picks up to `count` active products not already in the cart, on top of
+// whatever's already in `keep`. Deliberately does NOT fall back to
+// re-suggesting a cart item when the non-cart pool runs short (e.g. 2
+// active products total, 1 already in the cart) — the grid just renders
+// fewer, genuinely-relevant cards instead (see the centering logic in the
+// component below), rather than padding itself out with something the
+// shopper has already added.
+function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], count: number): string[] {
const missing = count - keep.length;
if (missing <= 0) return keep;
-
- let picks = pickRandom(allIds, [...excludeIds, ...keep], missing);
- if (picks.length < missing) {
- const stillMissing = missing - picks.length;
- const fallback = pickRandom(allIds, [...keep, ...picks], stillMissing);
- picks = [...picks, ...fallback];
- }
- return [...keep, ...picks];
+ return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
export function RelatedProducts() {
@@ -80,7 +72,7 @@ export function RelatedProducts() {
if (!pickedRef.current) {
pickedRef.current = true;
- setDisplayIds(pickWithFallback(productIds, cartIds, [], DISPLAY_COUNT));
+ setDisplayIds(pickAvailable(productIds, cartIds, [], DISPLAY_COUNT));
return;
}
@@ -92,7 +84,7 @@ export function RelatedProducts() {
swapTimeoutRef.current = setTimeout(() => {
setDisplayIds((prev) => {
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
- return pickWithFallback(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
+ return pickAvailable(productIds, cartIds, stillRelevant, DISPLAY_COUNT);
});
}, FEEDBACK_MS);
}, [cartKey, productIds]);
@@ -101,11 +93,9 @@ export function RelatedProducts() {
.map((id) => activeProducts.find((p) => p.id === id))
.filter((p): p is NonNullable => Boolean(p));
- // Section-wide gate, independent of cart contents or how many
- // displayProducts happen to resolve: with only 1 active product,
- // pickWithFallback's cart-item-reuse fallback could still populate a
- // card, but a "related products" section makes no sense with fewer than
- // 2 real alternatives to offer.
+ // Section-wide gate, independent of cart contents: a "related products"
+ // section makes no sense with fewer than 2 active products total to
+ // ever offer, even before considering what's already in the cart.
if (activeProducts.length < 2 || displayProducts.length === 0) return null;
return (
@@ -138,10 +128,24 @@ export function RelatedProducts() {
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}