From 0733b9d5df1ec7d9aaf90e6ad7530a4620b65bfe Mon Sep 17 00:00:00 2001 From: Rafiat Date: Thu, 30 Jul 2026 11:43:44 +0100 Subject: [PATCH 1/4] 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 356d945b4aad6487afcbf792b1a01a96fe223db7 Mon Sep 17 00:00:00 2001 From: Rafiat Date: Thu, 30 Jul 2026 20:43:49 +0300 Subject: [PATCH 2/4] 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(); + }); +}); From dfb73b9bc0ce9591b32df0ee87970c377495f13e Mon Sep 17 00:00:00 2001 From: Rafiat Date: Wed, 5 Aug 2026 15:14:30 +0100 Subject: [PATCH 3/4] fix(explore): skip scroll-to-top on initial mount The scroll-reset effect fired on first render as well as on actual category changes, which could fight the browser's scroll restoration when navigating back to /explore. Skip the first run with a ref so it only fires on real category changes. --- src/__tests__/integration/ExploreClient.test.tsx | 7 +++++++ src/app/[locale]/explore/ExploreClient.tsx | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/__tests__/integration/ExploreClient.test.tsx b/src/__tests__/integration/ExploreClient.test.tsx index 402b30df..651d074e 100644 --- a/src/__tests__/integration/ExploreClient.test.tsx +++ b/src/__tests__/integration/ExploreClient.test.tsx @@ -67,6 +67,13 @@ describe("ExploreClient category filter", () => { }); }); + it("does not scroll on initial mount", async () => { + render(); + + expect(await screen.findByText("Learner campaign")).toBeInTheDocument(); + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + it("scrolls back to the top when the active category changes", async () => { const user = userEvent.setup(); render(); diff --git a/src/app/[locale]/explore/ExploreClient.tsx b/src/app/[locale]/explore/ExploreClient.tsx index 916b298e..c9f16e0b 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 { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import CampaignStatusBadge from "@/components/CampaignStatusBadge"; import FundingProgressBar from "@/components/FundingProgressBar"; import { CampaignRowSkeleton } from "@/components/Skeleton"; @@ -27,8 +27,15 @@ export default function ExplorePage() { // 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. + // previous (now stale) scroll position. Skip the first run so this doesn't + // fight the browser's scroll restoration on initial mount (e.g. navigating + // back to /explore). + const isFirstRender = useRef(true); useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } window.scrollTo({ top: 0 }); }, [activeCategory]); From 5ebecd08ac72993989b5fb98aff29f30d30fd64c Mon Sep 17 00:00:00 2001 From: Rafiat Date: Wed, 5 Aug 2026 15:16:38 +0100 Subject: [PATCH 4/4] chore(ci): fix pre-existing CI failures inherited from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Format useMultiSigProposals.test.tsx (Prettier check was failing on it) - Add missing Causes.listView/mapView translation keys (en, es) — the view-toggle buttons on /causes reference these keys but they were never added, so next-intl throws MISSING_MESSAGE and the Playwright smoke test fails when it navigates through that page - Add the missing getAllCampaigns mock to the AppPageComponents contractClient mock, and cover the two HomeClient stats/CTA branches that were never exercised as a result (queryFn was undefined, so the stats panel and the wallet-connecting CTA state never rendered) None of this is related to this branch's actual change; main's CI was already failing on all three checks before this branch rebased onto it. --- messages/en.json | 4 +++- messages/es.json | 4 +++- .../hooks/useMultiSigProposals.test.tsx | 5 +---- .../integration/AppPageComponents.test.tsx | 21 ++++++++++++++++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/messages/en.json b/messages/en.json index 839aa0b0..fb8edcf5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -255,7 +255,9 @@ "tryDifferentSearch": "Try different search terms or clear the filters to see all causes.", "tryAgain": "Try again", "loadMore": "Load more causes", - "showingRange": "Showing {shown} of {total}" + "showingRange": "Showing {shown} of {total}", + "listView": "List", + "mapView": "Map" }, "Status": { "active": "Active", diff --git a/messages/es.json b/messages/es.json index 571cae86..7af41928 100644 --- a/messages/es.json +++ b/messages/es.json @@ -255,7 +255,9 @@ "tryDifferentSearch": "Intenta con otros términos de búsqueda o borra los filtros para ver todas las causas.", "tryAgain": "Intentar de nuevo", "loadMore": "Cargar más causas", - "showingRange": "Mostrando {shown} de {total}" + "showingRange": "Mostrando {shown} de {total}", + "listView": "Lista", + "mapView": "Mapa" }, "Status": { "active": "Activa", diff --git a/src/__tests__/hooks/useMultiSigProposals.test.tsx b/src/__tests__/hooks/useMultiSigProposals.test.tsx index b5a2d0d4..21e550a6 100644 --- a/src/__tests__/hooks/useMultiSigProposals.test.tsx +++ b/src/__tests__/hooks/useMultiSigProposals.test.tsx @@ -44,10 +44,7 @@ describe("useMultiSigProposals", () => { }); it("re-filters proposals when campaignId changes", () => { - seed([ - proposal({ id: "1-a" }), - proposal({ id: "2-a", campaignId: 2 }), - ]); + seed([proposal({ id: "1-a" }), proposal({ id: "2-a", campaignId: 2 })]); const { result, rerender } = renderHook( ({ cid }: { cid: number }) => useMultiSigProposals(cid, WALLET), diff --git a/src/__tests__/integration/AppPageComponents.test.tsx b/src/__tests__/integration/AppPageComponents.test.tsx index 9915ca63..865e5c07 100644 --- a/src/__tests__/integration/AppPageComponents.test.tsx +++ b/src/__tests__/integration/AppPageComponents.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type React from "react"; @@ -128,6 +128,7 @@ jest.mock("@/components/cancelCampaignModal", () => ({ })); jest.mock("@/lib/contractClient", () => ({ + getAllCampaigns: jest.fn(() => Promise.resolve([])), getAdmin: jest.fn(() => Promise.resolve(ADMIN)), getPlatformFee: jest.fn(() => Promise.resolve(300)), updateAdmin: jest.fn(), @@ -216,6 +217,24 @@ describe("app page components", () => { expect(screen.getByRole("heading", { name: "heroTitle" })).toBeInTheDocument(); expect(mockConnectWallet).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText("statsRaised")).toBeInTheDocument(); + }); + expect(screen.getByText("statsCampaigns")).toBeInTheDocument(); + }); + + it("shows a connecting state on the CTA while the wallet is connecting", () => { + mockUseWallet.mockReturnValue({ + publicKey: null, + isWalletConnected: false, + connectWallet: mockConnectWallet, + isLoading: true, + }); + + render(withQueryClient()); + + expect(screen.getByRole("link", { name: /Connecting/i })).toBeInTheDocument(); }); it("renders the cause detail page with campaign data, voting, actions, and refund state", async () => {