feat(FN-208): implement sticky checkout bar on scroll (+1 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- chore(FN-208): format code with biome
- feat(FN-208): implement sticky checkout bar on scroll

Files changed:
.../subscription/__tests__/index.test.tsx          | 10 ++++
 .../routes/dashboard/subscription/index.test.tsx   |  9 ++++
 .../src/routes/dashboard/subscription/index.tsx    | 54 +++++++++++++++++++++-
 3 files changed, 71 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-208
This commit is contained in:
Fusion
2026-05-12 21:38:38 +00:00
parent 405c0067dc
commit d14657a2c1
3 changed files with 71 additions and 2 deletions

View File

@@ -133,6 +133,16 @@ vi.mock("@/lib/toast", () => ({ toast: mockToast }));
// ─── Mock canvas-confetti ───────────────────────────────────────────────────
vi.mock("canvas-confetti", () => ({ default: vi.fn() }));
// ─── Mock IntersectionObserver (not available in jsdom) ────────────────────
vi.stubGlobal(
"IntersectionObserver",
vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})),
);
// ─── Mock BrandSelector ─────────────────────────────────────────────────────
vi.mock("@/components/subscription/brand-selector", () => ({
BrandSelector: ({

View File

@@ -66,6 +66,15 @@ vi.mock("@/lib/user-settings", () => ({
// Mock canvas-confetti
vi.mock("canvas-confetti", () => ({ default: vi.fn() }));
// Mock IntersectionObserver (not available in jsdom)
const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
});
vi.stubGlobal("IntersectionObserver", mockIntersectionObserver);
// Mock lazy BrandSelector
vi.mock("@/components/subscription/brand-selector", () => ({
BrandSelector: () => <div data-testid="brand-selector">BrandSelector</div>,

View File

@@ -373,6 +373,25 @@ export function SubscriptionPage() {
expired: "outline",
};
// ─── Sticky bar state (before early returns so hook count is consistent) ─
const planCardsRef = useRef<HTMLDivElement>(null);
const [stickyBarVisible, setStickyBarVisible] = useState(false);
useEffect(() => {
const el = planCardsRef.current;
if (!selectedPlanKey || !el) {
setStickyBarVisible(false);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setStickyBarVisible(!entry.isIntersecting);
},
{ threshold: 0, rootMargin: "-80px 0px 0px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [selectedPlanKey]);
// ─── Loading state ─────────────────────────────────────────────────────────
if (isLoading) {
return (
@@ -510,7 +529,7 @@ export function SubscriptionPage() {
// ─── Normal subscription page ──────────────────────────────────────────────
return (
<div className="mx-auto max-w-5xl space-y-8">
<div className="mx-auto max-w-5xl space-y-8 pb-16">
<h2 className="text-2xl font-bold">{t("subscription.title")}</h2>
{/* Active Subscription Status */}
@@ -828,7 +847,7 @@ export function SubscriptionPage() {
{/* Plan Comparison Cards */}
<div>
<h3 className="mb-4 text-lg font-semibold">{t("subscription.planComparison")}</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div ref={planCardsRef} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{plans.map((plan) => {
const isSelected = selectedPlanKey === plan.key;
const price = billingPeriod === "monthly" ? plan.priceMonthly : plan.priceYearly;
@@ -1012,6 +1031,37 @@ export function SubscriptionPage() {
</div>
</>
)}
{/* Sticky Checkout Bar — appears when user scrolls past plan cards with a plan selected */}
{stickyBarVisible &&
selectedPlanKey &&
(() => {
const selectedPlan = plans.find((p) => p.key === selectedPlanKey);
const barPrice =
billingPeriod === "monthly"
? (selectedPlan?.priceMonthly ?? 0)
: (selectedPlan?.priceYearly ?? 0);
const barPeriodLabel =
billingPeriod === "monthly" ? t("common.perMonth") : t("common.perYear");
return (
<div className="fixed inset-x-0 bottom-0 z-50 animate-fade-in-up border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-3">
<div className="flex items-baseline gap-3">
<span className="text-sm font-semibold">
{t(`subscription.plans.${selectedPlanKey}.name`)}
</span>
<span className="text-sm text-muted-foreground">
{formatTRY(barPrice)}
<span className="text-xs">{barPeriodLabel}</span>
</span>
</div>
<Button size="sm" className="px-6" onClick={handleProceedToPayment}>
{t("subscription.proceed")}
</Button>
</div>
</div>
);
})()}
</div>
);
}