From a764b979e51445692fb985843ea68cac2622967c Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:00 +0100 Subject: [PATCH 01/25] test(usePlatformFee): add scaffold and on-chain fee success case Verify usePlatformFee returns the live get_platform_fee value when the contract getter succeeds. Co-authored-by: Cursor --- src/__tests__/hooks/usePlatformFee.test.tsx | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/__tests__/hooks/usePlatformFee.test.tsx diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx new file mode 100644 index 0000000..85d0764 --- /dev/null +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -0,0 +1,38 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { usePlatformFee, DEFAULT_PLATFORM_FEE_BPS } from "@/hooks/usePlatformFee"; + +jest.mock("@/lib/contractClient", () => ({ + getPlatformFee: jest.fn(), +})); + +import { getPlatformFee } from "@/lib/contractClient"; + +const mockGetPlatformFee = getPlatformFee as jest.MockedFunction; + +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("usePlatformFee", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns on-chain fee when getPlatformFee succeeds", async () => { + mockGetPlatformFee.mockResolvedValue(250); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.platformFeeBps).toBe(250); + expect(result.current.isFallback).toBe(false); + }); +}); From c5eefe4b1e39d73b575c2703757372941cfbe276 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:03 +0100 Subject: [PATCH 02/25] test(usePlatformFee): verify 300 bps fallback when getter throws When get_platform_fee is unavailable the hook must surface the documented 300 bps default per CONTRACT_INTEGRATION.md. Co-authored-by: Cursor --- src/__tests__/hooks/usePlatformFee.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx index 85d0764..50a4f76 100644 --- a/src/__tests__/hooks/usePlatformFee.test.tsx +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -35,4 +35,15 @@ describe("usePlatformFee", () => { expect(result.current.platformFeeBps).toBe(250); expect(result.current.isFallback).toBe(false); }); + + it("falls back to 300 bps when getPlatformFee throws", async () => { + mockGetPlatformFee.mockRejectedValue(new Error("getter unavailable")); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); + expect(result.current.isFallback).toBe(true); + }); }); From 36359eb29cc517636a893672fcb4148248f02f5b Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:07 +0100 Subject: [PATCH 03/25] test(usePlatformFee): assert isFallback when on-chain data is absent Cover the isFallback flag so UI can distinguish live fee reads from the 300 bps default path. Co-authored-by: Cursor --- src/__tests__/hooks/usePlatformFee.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx index 50a4f76..831288b 100644 --- a/src/__tests__/hooks/usePlatformFee.test.tsx +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -46,4 +46,14 @@ describe("usePlatformFee", () => { expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); expect(result.current.isFallback).toBe(true); }); + + it("marks isFallback true while data is still undefined after error", async () => { + mockGetPlatformFee.mockRejectedValue(new Error("rpc down")); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); + + await waitFor(() => expect(result.current.isFallback).toBe(true)); + }); }); From 8521ed2ed63bd1ad6037df867e762467280a60c0 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:10 +0100 Subject: [PATCH 04/25] test(usePlatformFee): cover loading state during fee fetch Ensure consumers can show a loading indicator while get_platform_fee resolves. Co-authored-by: Cursor --- src/__tests__/hooks/usePlatformFee.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx index 831288b..97b2df4 100644 --- a/src/__tests__/hooks/usePlatformFee.test.tsx +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -56,4 +56,13 @@ describe("usePlatformFee", () => { await waitFor(() => expect(result.current.isFallback).toBe(true)); }); + + it("exposes isLoading while the platform fee query is in flight", () => { + mockGetPlatformFee.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); + }); }); From d446b3602232aac84322a20aba53cc549a878d0c Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:14 +0100 Subject: [PATCH 05/25] test(usePlatformFee): verify fee and net preview math from bps Confirm the returned basis points drive correct fee/net breakdown values used by withdrawal and detail previews. Co-authored-by: Cursor --- src/__tests__/hooks/usePlatformFee.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx index 97b2df4..2837022 100644 --- a/src/__tests__/hooks/usePlatformFee.test.tsx +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -65,4 +65,19 @@ describe("usePlatformFee", () => { expect(result.current.isLoading).toBe(true); expect(result.current.platformFeeBps).toBe(DEFAULT_PLATFORM_FEE_BPS); }); + + it("supports fee and net previews from platformFeeBps", async () => { + mockGetPlatformFee.mockResolvedValue(300); + + const { result } = renderHook(() => usePlatformFee(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const totalRaised = 100; + const feeAmount = totalRaised * (result.current.platformFeeBps / 10_000); + const creatorNet = totalRaised - feeAmount; + + expect(feeAmount).toBeCloseTo(3); + expect(creatorNet).toBeCloseTo(97); + }); }); From 5d0f8c12778e90d9f52371e26cd2a659785605e7 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:19 +0100 Subject: [PATCH 06/25] feat(useCampaign): expose notFound for missing campaign ids Align the hook with CONTRACT_INTEGRATION.md so detail pages can distinguish an unknown id from a still-loading query. Co-authored-by: Cursor --- src/hooks/useCampaign.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useCampaign.ts b/src/hooks/useCampaign.ts index 2f3ceba..7cc1069 100644 --- a/src/hooks/useCampaign.ts +++ b/src/hooks/useCampaign.ts @@ -9,6 +9,7 @@ export interface UseCampaignResult { campaign: Campaign | null; isLoading: boolean; error: string | null; + notFound: boolean; refetch: () => void; } @@ -34,6 +35,7 @@ export function useCampaign(id: number): UseCampaignResult { campaign: data ?? null, isLoading, error: error?.message ?? null, + notFound: !isLoading && !error && data === null && !!id, refetch: () => { queryClient.invalidateQueries({ queryKey: ['campaign', id] }); }, From edae5f9d5e4d99e20c607fb253d59d21497c0e63 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:20 +0100 Subject: [PATCH 07/25] test(useCampaign): add scaffold and loading-to-success transition Cover the happy path where get_campaign resolves and the hook leaves the loading state with campaign data. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaign.test.tsx | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/__tests__/hooks/useCampaign.test.tsx diff --git a/src/__tests__/hooks/useCampaign.test.tsx b/src/__tests__/hooks/useCampaign.test.tsx new file mode 100644 index 0000000..37a293c --- /dev/null +++ b/src/__tests__/hooks/useCampaign.test.tsx @@ -0,0 +1,69 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useCampaign } from "@/hooks/useCampaign"; +import { Category, type Campaign } from "@/types"; + +jest.mock("@/lib/contractClient", () => ({ + getCampaign: jest.fn(), +})); + +jest.mock("@/hooks/useWindowVisibility", () => ({ + useWindowVisibility: () => true, +})); + +import { getCampaign } from "@/lib/contractClient"; + +const mockGetCampaign = getCampaign as jest.MockedFunction; + +function makeCampaign(overrides: Partial = {}): Campaign { + return { + id: 1, + creator: "GCREATOR1111111111111111111111111111111111111111111111111", + title: "Test 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, + }; +} + +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useCampaign", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("transitions from loading to success with campaign data", async () => { + mockGetCampaign.mockResolvedValue(makeCampaign()); + + const { result } = renderHook(() => useCampaign(1), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.campaign).toBeNull(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaign?.title).toBe("Test Campaign"); + expect(result.current.error).toBeNull(); + expect(result.current.notFound).toBe(false); + }); +}); From 15c0b864625b2a5c79afe84696ee88c170577379 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:23 +0100 Subject: [PATCH 08/25] test(useCampaign): cover loading-to-error transition Verify query failures surface an error message without marking the campaign as not found. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaign.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/hooks/useCampaign.test.tsx b/src/__tests__/hooks/useCampaign.test.tsx index 37a293c..4302f51 100644 --- a/src/__tests__/hooks/useCampaign.test.tsx +++ b/src/__tests__/hooks/useCampaign.test.tsx @@ -66,4 +66,16 @@ describe("useCampaign", () => { expect(result.current.error).toBeNull(); expect(result.current.notFound).toBe(false); }); + + it("transitions from loading to error when getCampaign throws", async () => { + mockGetCampaign.mockRejectedValue(new Error("rpc unavailable")); + + const { result } = renderHook(() => useCampaign(1), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaign).toBeNull(); + expect(result.current.error).toBe("rpc unavailable"); + expect(result.current.notFound).toBe(false); + }); }); From b0741aa8fd46812840435402e5ba97dd2de622d0 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:27 +0100 Subject: [PATCH 09/25] test(useCampaign): assert notFound for unknown ids When get_campaign returns null the hook should expose notFound so detail pages can render a 404-style state. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaign.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/hooks/useCampaign.test.tsx b/src/__tests__/hooks/useCampaign.test.tsx index 4302f51..9ba4772 100644 --- a/src/__tests__/hooks/useCampaign.test.tsx +++ b/src/__tests__/hooks/useCampaign.test.tsx @@ -78,4 +78,16 @@ describe("useCampaign", () => { expect(result.current.error).toBe("rpc unavailable"); expect(result.current.notFound).toBe(false); }); + + it("returns notFound for unknown campaign ids", async () => { + mockGetCampaign.mockResolvedValue(null); + + const { result } = renderHook(() => useCampaign(999), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaign).toBeNull(); + expect(result.current.notFound).toBe(true); + expect(result.current.error).toBeNull(); + }); }); From e5b3ddac04641ec90e33b214f6adb06a2afaa9d6 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:30 +0100 Subject: [PATCH 10/25] test(useCampaign): verify refetch invalidates and re-queries Ensure refetch triggers a fresh get_campaign call and updates hook state. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaign.test.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/__tests__/hooks/useCampaign.test.tsx b/src/__tests__/hooks/useCampaign.test.tsx index 9ba4772..e17d70a 100644 --- a/src/__tests__/hooks/useCampaign.test.tsx +++ b/src/__tests__/hooks/useCampaign.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useCampaign } from "@/hooks/useCampaign"; @@ -90,4 +90,21 @@ describe("useCampaign", () => { expect(result.current.notFound).toBe(true); expect(result.current.error).toBeNull(); }); + + it("refetch re-queries and updates campaign state", async () => { + mockGetCampaign + .mockResolvedValueOnce(makeCampaign({ title: "First fetch" })) + .mockResolvedValueOnce(makeCampaign({ title: "After refetch" })); + + const { result } = renderHook(() => useCampaign(1), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.campaign?.title).toBe("First fetch")); + + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => expect(result.current.campaign?.title).toBe("After refetch")); + expect(mockGetCampaign).toHaveBeenCalledTimes(2); + }); }); From ed2b84bc788496c0f1fe8401f1d0983e59da9a10 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:35 +0100 Subject: [PATCH 11/25] test(useCampaigns): cover loading-to-success list fetch Verify getAllCampaigns populates the campaigns array after the initial query resolves. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaigns.test.tsx | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/__tests__/hooks/useCampaigns.test.tsx diff --git a/src/__tests__/hooks/useCampaigns.test.tsx b/src/__tests__/hooks/useCampaigns.test.tsx new file mode 100644 index 0000000..a4f3f67 --- /dev/null +++ b/src/__tests__/hooks/useCampaigns.test.tsx @@ -0,0 +1,67 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useCampaigns } from "@/hooks/useCampaigns"; +import { Category, type Campaign } from "@/types"; + +jest.mock("@/lib/contractClient", () => ({ + getAllCampaigns: jest.fn(), +})); + +jest.mock("@/hooks/useWindowVisibility", () => ({ + useWindowVisibility: () => true, +})); + +import { getAllCampaigns } from "@/lib/contractClient"; + +const mockGetAllCampaigns = getAllCampaigns as jest.MockedFunction; + +function makeCampaign(id: number): Campaign { + return { + id, + creator: "GCREATOR1111111111111111111111111111111111111111111111111", + title: `Campaign ${id}`, + 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, + }; +} + +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useCampaigns", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("transitions from loading to success with campaign list", async () => { + mockGetAllCampaigns.mockResolvedValue([makeCampaign(1), makeCampaign(2)]); + + const { result } = renderHook(() => useCampaigns(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.campaigns).toEqual([]); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaigns).toHaveLength(2); + expect(result.current.error).toBeNull(); + }); +}); From c3230a66019384cdfcc9025827b0c994bec13397 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:40 +0100 Subject: [PATCH 12/25] test(useCampaigns): cover loading-to-error transition Verify list fetch failures surface an error without leaving stale campaign data behind. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaigns.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/__tests__/hooks/useCampaigns.test.tsx b/src/__tests__/hooks/useCampaigns.test.tsx index a4f3f67..34e36fb 100644 --- a/src/__tests__/hooks/useCampaigns.test.tsx +++ b/src/__tests__/hooks/useCampaigns.test.tsx @@ -64,4 +64,15 @@ describe("useCampaigns", () => { expect(result.current.campaigns).toHaveLength(2); expect(result.current.error).toBeNull(); }); + + it("transitions from loading to error when getAllCampaigns throws", async () => { + mockGetAllCampaigns.mockRejectedValue(new Error("network failure")); + + const { result } = renderHook(() => useCampaigns(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.campaigns).toEqual([]); + expect(result.current.error).toBe("network failure"); + }); }); From 2edd274e6e70c6cfc3cb6e3d9abdc8941d0077fa Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:42 +0100 Subject: [PATCH 13/25] test(useCampaigns): verify refetch updates listing state Ensure invalidateQueries triggers a fresh getAllCampaigns call and refreshes the campaigns array. Co-authored-by: Cursor --- src/__tests__/hooks/useCampaigns.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/__tests__/hooks/useCampaigns.test.tsx b/src/__tests__/hooks/useCampaigns.test.tsx index 34e36fb..bf6fe25 100644 --- a/src/__tests__/hooks/useCampaigns.test.tsx +++ b/src/__tests__/hooks/useCampaigns.test.tsx @@ -75,4 +75,21 @@ describe("useCampaigns", () => { expect(result.current.campaigns).toEqual([]); expect(result.current.error).toBe("network failure"); }); + + it("refetch re-queries and updates the campaign list", async () => { + mockGetAllCampaigns + .mockResolvedValueOnce([makeCampaign(1)]) + .mockResolvedValueOnce([makeCampaign(1), makeCampaign(2)]); + + const { result } = renderHook(() => useCampaigns(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.campaigns).toHaveLength(1)); + + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => expect(result.current.campaigns).toHaveLength(2)); + expect(mockGetAllCampaigns).toHaveBeenCalledTimes(2); + }); }); From e9b3f4bcec1a5f820ad50766c0ef02f54633313e Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:45 +0100 Subject: [PATCH 14/25] test(WithdrawFunds): add scaffold and creator-only visibility Only the campaign creator should see the withdrawal panel; other wallets render nothing. Co-authored-by: Cursor --- .../components/WithdrawFunds.test.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/__tests__/components/WithdrawFunds.test.tsx diff --git a/src/__tests__/components/WithdrawFunds.test.tsx b/src/__tests__/components/WithdrawFunds.test.tsx new file mode 100644 index 0000000..c6d1c0c --- /dev/null +++ b/src/__tests__/components/WithdrawFunds.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import WithdrawFunds from "@/components/WithdrawFunds"; +import { Category, type Campaign } from "@/types"; + +jest.mock("@/lib/contractClient", () => ({ + withdrawFunds: jest.fn(), +})); + +jest.mock("@/components/ToastProvider", () => ({ + useToast: () => ({ + showError: jest.fn(), + showSuccess: jest.fn(), + }), +})); + +jest.mock("@/hooks/useWriteGuard", () => ({ + useWriteGuard: () => ({ + invoke: jest.fn(), + isPending: () => false, + }), +})); + +const CREATOR = "GCREATOR1111111111111111111111111111111111111111111111111"; + +function makeCampaign(overrides: Partial = {}): Campaign { + return { + id: 1, + creator: CREATOR, + title: "Test Campaign", + description: "Desc", + created_at: 1, + status: "active", + funding_goal: BigInt(100_000_000), + deadline: Math.floor(Date.now() / 1000) - 3600, + amount_raised: BigInt(100_000_000), + is_active: false, + funds_withdrawn: false, + is_cancelled: false, + is_verified: true, + category: Category.Educator, + has_revenue_sharing: false, + revenue_share_percentage: 0, + ...overrides, + }; +} + +describe("WithdrawFunds", () => { + it("renders nothing for non-creator wallets", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); From d9e4df0cf914a5624be907ad040d625c87ababbd Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:49 +0100 Subject: [PATCH 15/25] test(WithdrawFunds): verify fee and net breakdown display Confirm total raised, platform fee, and creator net amounts render correctly from the supplied basis points. Co-authored-by: Cursor --- src/__tests__/components/WithdrawFunds.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/__tests__/components/WithdrawFunds.test.tsx b/src/__tests__/components/WithdrawFunds.test.tsx index c6d1c0c..c82de72 100644 --- a/src/__tests__/components/WithdrawFunds.test.tsx +++ b/src/__tests__/components/WithdrawFunds.test.tsx @@ -55,4 +55,20 @@ describe("WithdrawFunds", () => { expect(container).toBeEmptyDOMElement(); }); + + it("shows fee and net breakdown when goal is reached", () => { + render( + , + ); + + expect(screen.getByText("Total raised")).toBeInTheDocument(); + expect(screen.getByText("Platform fee (3%)")).toBeInTheDocument(); + expect(screen.getByText("You will receive")).toBeInTheDocument(); + expect(screen.getByText("-0.3 XLM")).toBeInTheDocument(); + expect(screen.getByText("9.7 XLM")).toBeInTheDocument(); + }); }); From d1e26fdbc75d2aac0fe1defb345b1501b1d6eb89 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:51 +0100 Subject: [PATCH 16/25] test(WithdrawFunds): guard withdrawal before goal is reached Disable the withdraw button and show FundingGoalNotReached guidance when amount_raised is below funding_goal. Co-authored-by: Cursor --- src/__tests__/components/WithdrawFunds.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/__tests__/components/WithdrawFunds.test.tsx b/src/__tests__/components/WithdrawFunds.test.tsx index c82de72..b78421d 100644 --- a/src/__tests__/components/WithdrawFunds.test.tsx +++ b/src/__tests__/components/WithdrawFunds.test.tsx @@ -71,4 +71,19 @@ describe("WithdrawFunds", () => { expect(screen.getByText("-0.3 XLM")).toBeInTheDocument(); expect(screen.getByText("9.7 XLM")).toBeInTheDocument(); }); + + it("blocks withdrawal when funding goal has not been reached", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled(); + expect(screen.getByText("Funding goal has not been reached")).toBeInTheDocument(); + }); }); From 5eec26804304e31af2f6661e2bc0690f7a19ec01 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:53 +0100 Subject: [PATCH 17/25] test(WithdrawFunds): cover FundsAlreadyWithdrawn disabled state When funds_withdrawn is true the panel must stay disabled with a clear already-withdrawn message. Co-authored-by: Cursor --- src/__tests__/components/WithdrawFunds.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/components/WithdrawFunds.test.tsx b/src/__tests__/components/WithdrawFunds.test.tsx index b78421d..b015ada 100644 --- a/src/__tests__/components/WithdrawFunds.test.tsx +++ b/src/__tests__/components/WithdrawFunds.test.tsx @@ -86,4 +86,16 @@ describe("WithdrawFunds", () => { expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled(); expect(screen.getByText("Funding goal has not been reached")).toBeInTheDocument(); }); + + it("handles already-withdrawn state", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled(); + expect(screen.getByText("Funds have already been withdrawn")).toBeInTheDocument(); + }); }); From 5d576d79ba2d461c64833026ef82faf7d22888c9 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:22:55 +0100 Subject: [PATCH 18/25] test(WithdrawFunds): verify 3% default fee fallback in breakdown When platformFeeBps is omitted the component should use the documented 300 bps default for fee/net previews. Co-authored-by: Cursor --- src/__tests__/components/WithdrawFunds.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/components/WithdrawFunds.test.tsx b/src/__tests__/components/WithdrawFunds.test.tsx index b015ada..ace8a55 100644 --- a/src/__tests__/components/WithdrawFunds.test.tsx +++ b/src/__tests__/components/WithdrawFunds.test.tsx @@ -98,4 +98,16 @@ describe("WithdrawFunds", () => { expect(screen.getByRole("button", { name: "Withdraw Funds" })).toBeDisabled(); expect(screen.getByText("Funds have already been withdrawn")).toBeInTheDocument(); }); + + it("defaults to 300 bps platform fee when prop is omitted", () => { + render( + , + ); + + expect(screen.getByText("Platform fee (3%)")).toBeInTheDocument(); + expect(screen.getByText("-0.3 XLM")).toBeInTheDocument(); + }); }); From 27efd862f3e29bd7ac760c887facce80a4e3e5f5 Mon Sep 17 00:00:00 2001 From: 0x860 Date: Fri, 29 May 2026 22:23:02 +0100 Subject: [PATCH 19/25] feat(DonationModal): show platform fee explanation to contributors Surface the withdrawal-time platform fee so the contribution form matches CONTRACT_INTEGRATION contributor transparency requirements. Co-authored-by: Cursor --- src/components/DonationModal.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/DonationModal.tsx b/src/components/DonationModal.tsx index 876c796..844d613 100644 --- a/src/components/DonationModal.tsx +++ b/src/components/DonationModal.tsx @@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from "react"; import { contribute } from "../lib/contractClient"; -import { Campaign, xlmToStroops, formatStroopsAsXlm, calculateFundingPercentage } from "../types"; +import { Campaign, xlmToStroops, formatStroopsAsXlm, calculateFundingPercentage, basisPointsToPercentage } from "../types"; import { useToast } from "./ToastProvider"; import { useWallet } from "./WalletContext"; +import { usePlatformFee } from "../hooks/usePlatformFee"; import { parseContractError } from "../utils/contractErrors"; import { type TransactionLifecyclePhase } from "../lib/contractClient"; import { validateContributorNotCreator } from "../utils/validators"; @@ -29,6 +30,7 @@ type Step = "input" | "pending" | "confirmed"; export default function DonationModal({ campaign, onClose, onSuccess }: DonationModalProps) { const { publicKey } = useWallet(); const { showError } = useToast(); + const { platformFeeBps } = usePlatformFee(); const [amount, setAmount] = useState(""); const [step, setStep] = useState("input"); @@ -306,6 +308,11 @@ export default function DonationModal({ campaign, onClose, onSuccess }: Donation )} +

+ A platform fee of {basisPointsToPercentage(platformFeeBps)} is deducted from funds + when withdrawn by the creator. Your full donation goes toward the campaign total. +

+