fix(cart): only hide free-shipping banner after it's actually been seen

The hide-timer used to fire on a plain phase==="success" timeout,
regardless of whether the banner was actually on screen — if the
threshold was reached while scrolled away, it could hide itself before
the user ever saw it.

Gates the timer on continuous IntersectionObserver visibility
(useInView, no `once`) instead of a lifetime "ever visible" flag — the
latter flips true as soon as the page loads (the banner sits at the
top), which defeats the purpose entirely. The timer now only runs
while the banner is actually in view, and restarts if the user scrolls
away and back before it completes.
This commit is contained in:
Marco
2026-07-21 11:15:51 +00:00
parent cc9da6ac6d
commit a560ec9434
+22 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
import { formatPrice } from "../../lib/format";
const SUCCESS_VISIBLE_MS = 2500;
@@ -53,14 +53,29 @@ function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; th
}
}
// Gates the hide-timer on the banner being CURRENTLY visible — deliberately
// not `{ once: true }`: since the banner sits at the very top of the cart
// page, it's already visible the instant the page loads (well before the
// user ever scrolls anywhere), so a lifetime "has this ever been seen"
// flag flips true immediately and defeats the whole point — reaching the
// threshold later while scrolled away would still start the timer right
// then, exactly the bug this was meant to fix. Continuous tracking
// instead: the effect below only runs the countdown while `isInView` is
// true, and its own cleanup cancels it the moment the banner scrolls back
// out of view — so it always takes a full uninterrupted 2.5s of the
// banner actually being on screen before it's allowed to hide, restarting
// if the user looks away mid-countdown and comes back.
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
// 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.
// whenever phase changes away from "success", the banner scrolls out of
// view, or on unmount.
useEffect(() => {
if (phase !== "success") return;
if (phase !== "success" || !isInView) return;
const t = setTimeout(() => setPhase("hidden"), SUCCESS_VISIBLE_MS);
return () => clearTimeout(t);
}, [phase]);
}, [phase, isInView]);
const remaining = Math.max(0, threshold - subtotal);
const progressPct = Math.min(100, (subtotal / threshold) * 100);
@@ -73,6 +88,7 @@ function FreeShippingBannerInner({ subtotal, threshold }: { subtotal: number; th
<AnimatePresence>
{phase !== "hidden" && (
<motion.div
ref={ref}
initial={false}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}