diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx
index 864492b..7507abb 100644
--- a/app/cart/components/CartContent.tsx
+++ b/app/cart/components/CartContent.tsx
@@ -8,6 +8,7 @@ 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";
+import { FreeShippingBanner } from "./FreeShippingBanner";
export function CartContent() {
const [versandOpen, setVersandOpen] = useState(false);
@@ -54,7 +55,11 @@ export function CartContent() {
) : (
-
+ <>
+
+
+
+
{/* 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
@@ -210,6 +215,7 @@ export function CartContent() {
+ >
)}
{items.length > 0 && (
diff --git a/app/cart/components/FreeShippingBanner.tsx b/app/cart/components/FreeShippingBanner.tsx
new file mode 100644
index 0000000..5cbd8c0
--- /dev/null
+++ b/app/cart/components/FreeShippingBanner.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
+import { formatPrice } from "../../lib/products";
+
+const SUCCESS_VISIBLE_MS = 2500;
+
+type Phase = "progress" | "success" | "hidden";
+
+/**
+ * Progress nudge toward free shipping — shown only in the cart, not on
+ * /shop, per the deliberately narrower scope agreed with the user (a cart
+ * item can leave/re-enter the threshold as quantities change, so this
+ * needs to react to that, not just fire once).
+ */
+export function FreeShippingBanner({ subtotal }: { subtotal: number }) {
+ const reached = subtotal >= FREE_SHIPPING_THRESHOLD;
+ const [phase, setPhase] = useState(reached ? "success" : "progress");
+
+ // React's documented pattern for "adjust state when a prop changes" —
+ // done during render (guarded by comparing against a mirrored previous
+ // value), not inside a useEffect. `reached` isn't an external system to
+ // sync with, it's plain derived input; setState synchronously inside an
+ // effect body would just cost an extra render pass for no benefit and
+ // trips the react-hooks/set-state-in-effect rule. No ref/timer touched
+ // here either — the success→hidden timer below owns its own cleanup.
+ const [prevReached, setPrevReached] = useState(reached);
+ if (reached !== prevReached) {
+ setPrevReached(reached);
+ if (!reached) {
+ // Dropping back below the threshold (e.g. removing an item after
+ // having qualified) always re-shows the progress bar — not just the
+ // first time, every time.
+ setPhase("progress");
+ } else {
+ // Just crossed the threshold — celebrate briefly, then collapse the
+ // banner entirely rather than let it sit there taking up space.
+ setPhase((prev) => (prev === "progress" ? "success" : prev));
+ }
+ }
+
+ // Effect's own cleanup (not a ref) cancels the pending hide-timer
+ // whenever phase changes away from "success" (e.g. dropping back below
+ // the threshold before the timer fires) or on unmount.
+ useEffect(() => {
+ if (phase !== "success") return;
+ const t = setTimeout(() => setPhase("hidden"), SUCCESS_VISIBLE_MS);
+ return () => clearTimeout(t);
+ }, [phase]);
+
+ if (phase === "hidden") return null;
+
+ const remaining = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);
+ const progressPct = Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100);
+
+ return (
+
+
+ {phase === "success"
+ ? "Kostenloser Versand freigeschaltet ✓"
+ : `Noch ${formatPrice(remaining)} bis zum kostenlosen Versand!`}
+
+
+
+ );
+}