diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx new file mode 100644 index 0000000..864492b --- /dev/null +++ b/app/cart/components/CartContent.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { useCart, removeFromCart, setQuantity } from "../../lib/cart"; +import { PRODUCTS, formatPrice } from "../../lib/products"; +import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping"; +import { Reveal } from "../../components/Reveal"; +import { VersandModal } from "../../components/VersandModal"; + +export function CartContent() { + const [versandOpen, setVersandOpen] = useState(false); + const cart = useCart(); + const items = cart + .map((entry) => ({ entry, product: PRODUCTS[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 shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST; + const total = subtotal + shipping; + + return ( + <> + {/* Page header */} + +

+ Startseite + + Warenkorb +

+

+ Warenkorb +

+

+ {items.length > 0 ? "Schön, dass du da bist." : "Dein Warenkorb ist noch leer."} +

+
+ + {items.length === 0 ? ( + +

+ Schau dir unsere Produkte an und finde, was zu dir passt. +

+ + + Weiter einkaufen + +
+ ) : ( +
+ {/* Cart card — lg:-only split from the sidebar (same "wide content + next to sidebar" shape as the Hero's image/text split, see + figma-to-nextjs skill Gotcha #5: Figma's 830px card alone + already exceeds the 768px Tablet floor, so md: would never + have had room for a real 2-column layout at Tablet widths + anyway). */} + + {items.map(({ entry, product }, i) => ( +
+ {i > 0 &&
} +
+
+ {product.name} +
+
+

+ {product.name} +

+

{product.description}

+
+

Einzelpreis

+

+ {formatPrice(product.price)} + inkl. MwSt. +

+
+
+
+ + +

+ {formatPrice(entry.qty * product.price)} +

+ +
+
+
+ ))} + +
+ + + + Weiter einkaufen + + + + {/* Sidebar */} + +
+

+ Bestellübersicht +

+ +
+ Zwischensumme + + {formatPrice(subtotal)} +
+ +
+
+ + Versand + + + + + {shipping === 0 ? "Kostenlos" : formatPrice(shipping)} + +
+

+ {shipping === 0 + ? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands` + : "innerhalb Deutschlands"} +

+
+ +
+ +
+
+ + Gesamtsumme + + + {formatPrice(total)} +
+

inkl. MwSt.

+
+ + + Zur Kasse gehen + + +
+ + Sichere Zahlung +
+
+ +
+ {[ + { icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" }, + { icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" }, + { icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" }, + ].map((b) => ( +
+ + {b.text} +
+ ))} +
+ +
+ )} + + {items.length > 0 && ( + +
+
+ +
+
+
+

+ Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail. +

+

+ Für mehr Klarheit, Fokus und Struktur – jede Woche. +

+
+
+ + )} + + setVersandOpen(false)} /> + + ); +} diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx new file mode 100644 index 0000000..2c8f4c2 --- /dev/null +++ b/app/cart/components/RelatedProducts.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { PRODUCTS, RELATED_PRODUCT_IDS, formatPrice } from "../../lib/products"; +import { Reveal } from "../../components/Reveal"; +import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton"; +import { useCart } from "../../lib/cart"; + +const ALL_PRODUCT_IDS = Object.keys(PRODUCTS); +const DISPLAY_COUNT = 3; + +function pickRandom(excludeIds: string[], count: number): string[] { + const pool = ALL_PRODUCT_IDS.filter((id) => !excludeIds.includes(id)); + const shuffled = [...pool].sort(() => Math.random() - 0.5); + return shuffled.slice(0, count); +} + +export function RelatedProducts() { + const cart = useCart(); + const hasItems = cart.length > 0; + const cartKey = cart + .map((i) => i.id) + .sort() + .join(","); + + // Math.random() can't run during the render that has to match the SSR + // pass (that would be a hydration mismatch — server and client would + // pick different products) — so the first paint always shows the fixed + // RELATED_PRODUCT_IDS fallback, and a client-only effect swaps in the + // real, cart-aware random pick right after mount. + const [displayIds, setDisplayIds] = useState(RELATED_PRODUCT_IDS); + + // First run (mount/hydration): pick the initial random set. After that, + // don't reshuffle everything on every cart change — one of these cards' + // own AddToCartInlineButton adds to the cart without navigating away, + // and a full reshuffle would yank the other two cards out from under + // the user mid-browse. Instead, only swap out whichever displayed card + // just became "already in the cart" (no longer relevant to recommend) + // and top that one slot back up — the rest stay exactly where they were. + const pickedRef = useRef(false); + const swapTimeoutRef = useRef | undefined>(undefined); + + useEffect(() => () => clearTimeout(swapTimeoutRef.current), []); + + useEffect(() => { + const cartIds = cartKey ? cartKey.split(",") : []; + + if (!pickedRef.current) { + pickedRef.current = true; + setDisplayIds(pickRandom(cartIds, DISPLAY_COUNT)); + return; + } + + // Delayed by FEEDBACK_MS, not immediate — the add usually came from + // clicking one of THESE cards' own AddToCartInlineButton, which shows + // a ~2s green "Hinzugefügt ✓" state. Swapping the card out right away + // unmounts that button before the confirmation is ever visible. + clearTimeout(swapTimeoutRef.current); + swapTimeoutRef.current = setTimeout(() => { + setDisplayIds((prev) => { + const stillRelevant = prev.filter((id) => !cartIds.includes(id)); + const missing = DISPLAY_COUNT - stillRelevant.length; + if (missing <= 0) return stillRelevant; + + let replacements = pickRandom([...cartIds, ...stillRelevant], missing); + + // The catalog only has 5 products — once the cart holds 3+ + // distinct ones, "3 recommendations that aren't already in the + // cart" becomes mathematically impossible (5 - 3 in cart leaves + // only 2 to show). Falling back to re-suggesting something + // already in the cart (a normal "grab another one" pattern) beats + // silently shrinking the grid below DISPLAY_COUNT. + if (replacements.length < missing) { + const stillMissing = missing - replacements.length; + const fallback = pickRandom([...stillRelevant, ...replacements], stillMissing); + replacements = [...replacements, ...fallback]; + } + + return [...stillRelevant, ...replacements]; + }); + }, FEEDBACK_MS); + }, [cartKey]); + + if (displayIds.length === 0) return null; + + return ( +
+ +

+ {hasItems ? "Passt perfekt dazu" : "Beliebte Produkte"} +

+

+ {hasItems ? "Weitere Tools für deinen klaren Alltag." : "Entdecke, was zu dir passt."} +

+
+ + {/* No separate price-disclosure footnote here — the single + "* inkl. MwSt., zzgl. Versandkosten" note lives directly under + the cart's own product table instead (CartContent.tsx), close + enough on the same page view to cover these cards too. + + Plain divs, not RevealGroup/RevealItem — this is the one grid on + the site whose items get swapped after the initial mount (see + the swap-in-place effect above). RevealItem has no viewport + trigger of its own; it only ever renders visible because it + inherits the parent RevealGroup's already-resolved "show" state + at first mount. A card swapped in later doesn't get a fresh + trigger and can end up stuck at its hidden variant + (opacity: 0) — invisible. Not worth chasing a fix for a + scroll-reveal nicety on a list that mutates; a static grid + renders correctly with no animation risk. */} +
+ {displayIds.map((id) => { + const product = PRODUCTS[id]; + return ( +
+
+ {product.name} +
+
+

+ {product.name} +

+

{formatPrice(product.price)}

+ +
+
+ ); + })} +
+
+ ); +} diff --git a/app/cart/page.tsx b/app/cart/page.tsx new file mode 100644 index 0000000..69fcbe0 --- /dev/null +++ b/app/cart/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { CartContent } from "./components/CartContent"; +import { RelatedProducts } from "./components/RelatedProducts"; +import { TrustRow } from "../components/TrustRow"; +import { Footer } from "../components/Footer"; + +// robots: noindex — transactional page (mirrors a specific shopper's cart +// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing +// this wastes crawl budget and could surface cart state in search results. +export const metadata: Metadata = { + title: "Warenkorb", + description: "Dein Warenkorb bei einfach produktiv.", + robots: { + index: false, + follow: true, + }, +}; + +export default function CartPage() { + return ( + <> +
+ + + +
+