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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user