From af70f9d80012f824a0f80c412bd1656b73c41f78 Mon Sep 17 00:00:00 2001 From: Rafiat Date: Thu, 30 Jul 2026 11:43:44 +0100 Subject: [PATCH 1/2] fix(explore): reset scroll position when category filter changes Switching the active category previously left the viewport at its prior scroll offset, which could strand users deep in a now-stale list. Scroll back to the top whenever activeCategory changes. Refs #561 --- src/app/[locale]/explore/ExploreClient.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/explore/ExploreClient.tsx b/src/app/[locale]/explore/ExploreClient.tsx index 2812c3fb..916b298e 100644 --- a/src/app/[locale]/explore/ExploreClient.tsx +++ b/src/app/[locale]/explore/ExploreClient.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { Link } from "@/i18n/routing"; import { useTranslations, useLocale } from "next-intl"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import CampaignStatusBadge from "@/components/CampaignStatusBadge"; import FundingProgressBar from "@/components/FundingProgressBar"; import { CampaignRowSkeleton } from "@/components/Skeleton"; @@ -25,6 +25,13 @@ export default function ExplorePage() { const { campaigns, isLoading, error, refetch } = useCampaigns(); const [activeCategory, setActiveCategory] = useState<"all" | Category>("all"); + // Scroll back to the top of the list whenever the category filter changes, + // so switching categories doesn't leave the user stranded deep in the + // previous (now stale) scroll position. + useEffect(() => { + window.scrollTo({ top: 0 }); + }, [activeCategory]); + const categories = useMemo(() => { const seen = new Set(campaigns.map((c) => c.category)); return ["all" as const, ...Array.from(seen).sort((a, b) => a - b)]; From 9faeacee25b242b96b65c16764c7bda824a035f7 Mon Sep 17 00:00:00 2001 From: Rafiat Date: Thu, 30 Jul 2026 20:43:49 +0300 Subject: [PATCH 2/2] test(explore): cover category filter scroll reset Adds regression coverage for the scroll-to-top-on-category-change fix: confirms window.scrollTo({ top: 0 }) fires when the active category changes and the filtered list updates, and confirms it does not fire again for a re-click of the already-active category (no-op state change). Refs #561 --- .../integration/ExploreClient.test.tsx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/__tests__/integration/ExploreClient.test.tsx diff --git a/src/__tests__/integration/ExploreClient.test.tsx b/src/__tests__/integration/ExploreClient.test.tsx new file mode 100644 index 00000000..402b30df --- /dev/null +++ b/src/__tests__/integration/ExploreClient.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ExplorePage from "@/app/[locale]/explore/ExploreClient"; +import { Category, type Campaign } from "@/types"; + +const mockUseCampaigns = jest.fn(); + +jest.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +jest.mock("@/i18n/routing", () => ({ + Link: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +jest.mock("@/hooks/useCampaigns", () => ({ + useCampaigns: () => mockUseCampaigns(), +})); + +function makeCampaign(overrides: Partial = {}): Campaign { + return { + id: 1, + creator: "GCREATOR1111111111111111111111111111111111111111111111111", + title: "Campaign", + description: "Desc", + created_at: 1, + status: "active", + funding_goal: BigInt(100_000_000), + deadline: 9_999_999_999, + amount_raised: BigInt(10_000_000), + is_active: true, + funds_withdrawn: false, + is_cancelled: false, + is_verified: true, + category: Category.Educator, + has_revenue_sharing: false, + revenue_share_percentage: 0, + ...overrides, + }; +} + +describe("ExploreClient category filter", () => { + const scrollToSpy = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + window.scrollTo = scrollToSpy; + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + mockUseCampaigns.mockReturnValue({ + campaigns: [ + makeCampaign({ id: 1, title: "Learner campaign", category: Category.Learner }), + makeCampaign({ id: 2, title: "Educator campaign", category: Category.Educator }), + ], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + }); + + it("scrolls back to the top when the active category changes", async () => { + const user = userEvent.setup(); + render(); + + scrollToSpy.mockClear(); + + const educatorPill = screen.getByRole("button", { name: /Educator/i }); + await user.click(educatorPill); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 }); + expect(screen.getByText("Educator campaign")).toBeInTheDocument(); + expect(screen.queryByText("Learner campaign")).not.toBeInTheDocument(); + }); + + it("does not scroll again for renders that don't change the active category", async () => { + const user = userEvent.setup(); + render(); + + scrollToSpy.mockClear(); + + const allPill = screen.getByRole("button", { name: "all" }); + await user.click(allPill); + + expect(scrollToSpy).not.toHaveBeenCalled(); + }); +});