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!`} +

+
+
+
+
+ ); +}