Add free-shipping progress banner to the cart

Shows "Noch X € bis zum kostenlosen Versand" with a progress bar while
below the threshold; briefly confirms "freigeschaltet ✓" on crossing it,
then auto-hides. Re-appears if the subtotal drops back below the
threshold (e.g. after removing an item).
This commit is contained in:
Marco
2026-07-19 14:50:31 +00:00
parent 9e5532be63
commit c595e89305
2 changed files with 91 additions and 1 deletions
+7 -1
View File
@@ -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() {
</Link>
</Reveal>
) : (
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
<>
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
<FreeShippingBanner subtotal={subtotal} />
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
{/* 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() {
</div>
</Reveal>
</div>
</>
)}
{items.length > 0 && (
@@ -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<Phase>(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 (
<div
className={
"w-full rounded-md border p-4 flex flex-col gap-2 transition-colors duration-300 " +
(phase === "success" ? "border-success bg-success-subtle" : "border-border bg-bg-base")
}
>
<p
className={
"text-body-sm font-semibold " + (phase === "success" ? "text-success" : "text-text-primary")
}
>
{phase === "success"
? "Kostenloser Versand freigeschaltet ✓"
: `Noch ${formatPrice(remaining)} bis zum kostenlosen Versand!`}
</p>
<div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
<div
className={
"h-full rounded-full transition-[width] duration-500 ease-out " +
(phase === "success" ? "bg-success" : "bg-brand")
}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
);
}