From aec7ac09b05f162a2c8781b99483939c4d4fdb56 Mon Sep 17 00:00:00 2001 From: veloura-dev Date: Thu, 3 Sep 2026 23:47:23 +0000 Subject: [PATCH] #777 feat(campaign): Backer leaderboard show top supporters on campaign page FIXED --- apps/web/package.json | 4 +- .../(overview)/campaigns/[id]/page.test.tsx | 99 ++++ .../app/(overview)/campaigns/[id]/page.tsx | 112 ++--- .../web/src/app/(overview)/campaigns/page.tsx | 17 +- .../[id]/backers/featured/route.test.ts | 102 ++++ .../campaigns/[id]/backers/featured/route.ts | 99 ++++ .../api/campaigns/[id]/backers/route.test.ts | 139 ++++++ .../app/api/campaigns/[id]/backers/route.ts | 125 +++++ .../api/campaigns/success-stories/route.ts | 1 + .../campaign/backers/TopBackers.test.tsx | 162 ++++++ .../modules/campaign/backers/TopBackers.tsx | 296 +++++++++++ .../campaign/community/BackerCommunity.tsx | 4 +- .../success-stories/SuccessStories.tsx | 12 +- .../src/components/organisms/wallet-modal.tsx | 3 - apps/web/src/hooks/use-top-backers.ts | 133 +++++ apps/web/src/lib/co2-impact.ts | 10 +- .../services/campaign-backers.service.test.ts | 363 ++++++++++++++ .../src/services/campaign-backers.service.ts | 467 ++++++++++++++++++ apps/web/src/services/certificate.service.ts | 5 +- apps/web/src/types/campaign-backers.ts | 242 +++++++++ 20 files changed, 2288 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/app/(overview)/campaigns/[id]/page.test.tsx create mode 100644 apps/web/src/app/api/campaigns/[id]/backers/featured/route.test.ts create mode 100644 apps/web/src/app/api/campaigns/[id]/backers/featured/route.ts create mode 100644 apps/web/src/app/api/campaigns/[id]/backers/route.test.ts create mode 100644 apps/web/src/app/api/campaigns/[id]/backers/route.ts create mode 100644 apps/web/src/components/modules/campaign/backers/TopBackers.test.tsx create mode 100644 apps/web/src/components/modules/campaign/backers/TopBackers.tsx create mode 100644 apps/web/src/hooks/use-top-backers.ts create mode 100644 apps/web/src/services/campaign-backers.service.test.ts create mode 100644 apps/web/src/services/campaign-backers.service.ts create mode 100644 apps/web/src/types/campaign-backers.ts diff --git a/apps/web/package.json b/apps/web/package.json index d4e87cda..794c9ddb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,7 +34,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", - "@stellar/freghter-api": "^6.0.1", + "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^14.4.3", "@tanstack/react-query": "^5.90.20", "@tanstack/react-table": "^8.21.3", @@ -47,7 +47,6 @@ "framer-motion": "^12.28.1", "graphql": "^16.9.0", "graphql-yoga": "^5.7.0", - "html2canvas": "^1.4.1", "ioredis": "^6.0.0", "leaflet": "^1.9.4", "lucide-react": "^0.562.0", @@ -69,7 +68,6 @@ "@playwright/test": "^1.48.0", "@tailwindcss/postcss": "^4", "@testing-library/react": "^16.3.2", - "@types/html2canvas": "^1.2.6", "@types/node": "^20", "@types/qrcode": "^1.5.5", "@types/react": "^19", diff --git a/apps/web/src/app/(overview)/campaigns/[id]/page.test.tsx b/apps/web/src/app/(overview)/campaigns/[id]/page.test.tsx new file mode 100644 index 00000000..bca6df92 --- /dev/null +++ b/apps/web/src/app/(overview)/campaigns/[id]/page.test.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +// The page reads its route params with React 19's `use(params)`. jsdom's +// scheduler never flushes that suspension, so resolve the promise inline for +// tests — the component code path is otherwise untouched. +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + use: (value: unknown) => + value instanceof Promise ? { id: "camp-101" } : (actual as { use: (v: unknown) => unknown }).use(value), + }; +}); + +// Recharts measures its container; jsdom has no layout engine, so stub the +// observer the charts rely on. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal("ResizeObserver", ResizeObserverStub); + +import CampaignDetailPage from "./page"; +import { TopBackers } from "@/components/modules/campaign/backers/TopBackers"; +import { backersService, DEMO_CAMPAIGN_ID, DEMO_CREATOR_ADDRESS } from "@/services/campaign-backers.service"; + +const renderPage = () => render(); + +const openBackersTab = async () => { + const tab = screen.getByRole("tab", { name: /top backers/i }); + // Radix selects a tab on pointer-down rather than on click. + fireEvent.mouseDown(tab); + fireEvent.click(tab); + await act(async () => { + await Promise.resolve(); + }); +}; + +describe("Campaign detail page — top backers tab", () => { + it("exposes a Top Backers tab that renders the ranked leaderboard", async () => { + renderPage(); + await openBackersTab(); + + expect(screen.getByText("Top 10 Backers")).toBeTruthy(); + expect(screen.getAllByTestId(/^backer-row-/)).toHaveLength(10); + // The mock page renders as the campaign creator, who may resolve every + // backer, but opted-out backers are still badged so featuring is explained. + expect(screen.getByText("Anonymous")).toBeTruthy(); + expect(screen.getByText(/0\/3 featured/)).toBeTruthy(); + }); + + it("redacts the anonymous backer for everyone but the creator", async () => { + renderPage(); + await openBackersTab(); + + // Same campaign, public viewer: identity hidden, rank kept. + const publicBoard = backersService.getTopBackers(DEMO_CAMPAIGN_ID, { viewerAddress: "GPUBLIC...PPPP" }); + expect(publicBoard.backers.map((entry) => entry.displayName)).toContain("Anonymous backer"); + expect(publicBoard.backers.some((entry) => entry.displayName === "Ken Adeyemi")).toBe(false); + expect(publicBoard.privateBackers).toBe(1); + + // ...and a public viewer is told how many backers stay off the board. + render(); + expect(screen.getByText(/1 backer keeps their support private/)).toBeTruthy(); + expect(screen.getByText("Anonymous backer")).toBeTruthy(); + }); + + it("lets the creator feature a backer from the detail page", async () => { + renderPage(); + await openBackersTab(); + + // The mock page renders as the campaign creator, so featuring is available. + expect(backersService.getCampaignCreator(DEMO_CAMPAIGN_ID)).toBe(DEMO_CREATOR_ADDRESS); + const featureButtons = screen.getAllByRole("button", { name: /feature/i }); + expect(featureButtons.length).toBeGreaterThan(0); + + fireEvent.click(featureButtons[0]); + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText("Featured")).toBeTruthy(); + expect(screen.getByText(/1\/3 featured/)).toBeTruthy(); + }); + + it("renders one clean tab list after the merge repair", async () => { + renderPage(); + + expect(screen.getAllByRole("tablist")).toHaveLength(1); + expect(screen.getAllByRole("tab")).toHaveLength(8); + // Only the active tab's content is mounted. + expect(screen.getByText("Full Campaign Story")).toBeTruthy(); + // The duplicated insurance-claim modal is gone: exactly one trigger. + expect(screen.getAllByRole("button", { name: /submit insurance claim/i })).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/(overview)/campaigns/[id]/page.tsx b/apps/web/src/app/(overview)/campaigns/[id]/page.tsx index 5a7de5b7..96478f1f 100644 --- a/apps/web/src/app/(overview)/campaigns/[id]/page.tsx +++ b/apps/web/src/app/(overview)/campaigns/[id]/page.tsx @@ -11,14 +11,11 @@ import { Edit, ShieldCheck, MessageSquare, - Calendar, - Share2, - Edit, - ShieldCheck, Trophy, BarChart3, Globe, AlertTriangle, + Crown, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -30,6 +27,8 @@ import { CampaignQAModeration } from "@/components/modules/campaign/qa/CampaignQ import { CampaignSeries } from "@/components/modules/campaign/series/CampaignSeries"; import { CampaignAnalyticsDashboard } from "@/components/modules/campaign/analytics/CampaignAnalyticsDashboard"; import { BackerCommunity } from "@/components/modules/campaign/community/BackerCommunity"; +import { TopBackers } from "@/components/modules/campaign/backers/TopBackers"; +import { TOP_BACKERS_LIMIT } from "@/types/campaign-backers"; import { CampaignFundingVelocityChart } from "@/components/modules/campaign/FundingVelocityChart"; const translations = { @@ -112,6 +111,12 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s treesPlanted: "1,500", }; + // The mock detail page renders as the campaign creator, so creator-only + // controls (featuring backers, managing community spaces) are exercised. + // Replace with the connected wallet address once wallet state is wired here. + const viewerAddress = campaign.creator; + const isCreatorView = viewerAddress === campaign.creator; + const detectedLang = detectLanguage(campaign.title + campaign.shortDescription + campaign.fullStory); const detectedLanguageName = languageNames[detectedLang] ?? detectedLang; const [translationLang, setTranslationLang] = useState(""); @@ -159,7 +164,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s

Submit Insurance Claim

- +
{claimSubmitted ? (
@@ -185,7 +190,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s />
- +
@@ -270,25 +275,28 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s goalAmount={campaign.goalAmount} /> - {/* Main Content Tabs (Overview, Sponsor Wall #724, Co-Creators #722) */} + {/* Main Content Tabs (Overview, Sponsor Wall #724, Top Backers, Co-Creators #722) */} {/* Backer community spaces (#788) render inside the overview sidebar. */} - - - + Overview & Story Sponsor Wall (#724) + + Top Backers + Co-Creators (#722) Q&A (#791) + Success Story + Series & Sequels @@ -356,7 +364,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
- + @@ -366,15 +374,28 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s - {/* Tab 3: Co-Creators Collaboration (#722) */} + {/* Tab 3: Top backers by amount, with creator featuring + privacy */} + + + + + {/* Tab 4: Co-Creators Collaboration (#722) */} - {/* Tab 4: Q&A Moderation (#791) */} + {/* Tab 5: Q&A Moderation (#791) */} - {/* Tab 4: Success Story */} + + + {/* Tab 6: Success Story */}
@@ -395,79 +416,26 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
{campaign.successStory.backerTestimonials.map((testimonial) => (
-
"{testimonial.quote}"
+
“{testimonial.quote}”
- {testimonial.name} · {testimonial.location} + {testimonial.name} · {testimonial.location}
))}
+ - {/* Tab 4: Series & Sequels */} + {/* Tab 7: Series & Sequels */} - {/* Tab 5: Analytics for creators */} + {/* Tab 8: Analytics for creators */} - {showInsuranceModal && ( -
-
-
-

- Campaign Insurance Claim -

- -
- {claimSubmitted ? ( -

Claim submitted successfully.

- ) : ( -
{ - e.preventDefault(); - setClaimSubmitted(true); - }} - > -