Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions apps/web/src/app/(overview)/campaigns/[id]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("react")>();
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(<CampaignDetailPage params={Promise.resolve({ id: DEMO_CAMPAIGN_ID })} />);

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(<TopBackers campaignId={DEMO_CAMPAIGN_ID} viewerAddress="GPUBLIC...PPPP" />);
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);
});
});
112 changes: 40 additions & 72 deletions apps/web/src/app/(overview)/campaigns/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<TranslationKey | "">("");
Expand Down Expand Up @@ -159,7 +164,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
<div className="w-full max-w-md rounded-xl border border-zinc-700 bg-zinc-900 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-zinc-100">Submit Insurance Claim</h2>
<button onClick={() => setShowInsuranceModal(false)} className="text-zinc-400 hover:text-zinc-200 text-xl">×</button>
<button type="button" onClick={() => { setShowInsuranceModal(false); setClaimSubmitted(false); }} className="text-zinc-400 hover:text-zinc-200 text-xl">×</button>
</div>
{claimSubmitted ? (
<div className="space-y-2">
Expand All @@ -185,7 +190,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setShowInsuranceModal(false)}>Cancel</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setShowInsuranceModal(false); setClaimSubmitted(false); }}>Cancel</Button>
<Button type="submit" size="sm" className="bg-amber-600 text-white hover:bg-amber-700">Submit Claim</Button>
</div>
</form>
Expand Down Expand Up @@ -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. */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
<TabsList className="grid w-full grid-cols-3 bg-zinc-900 border border-zinc-800 p-1 rounded-xl">
<TabsList className="grid w-full grid-cols-4 bg-zinc-900 border border-zinc-800 p-1 rounded-xl">
<TabsList className="grid w-full grid-cols-5 bg-zinc-900 border border-zinc-800 p-1 rounded-xl">
<TabsList className="grid w-full grid-cols-2 gap-1 rounded-xl border border-zinc-800 bg-zinc-900 p-1 md:grid-cols-4 xl:grid-cols-8">
<TabsTrigger value="overview" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Target className="mr-1.5 h-4 w-4" /> Overview & Story
</TabsTrigger>
<TabsTrigger value="sponsors" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Heart className="mr-1.5 h-4 w-4 text-rose-400" /> Sponsor Wall (#724)
</TabsTrigger>
<TabsTrigger value="backers" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Crown className="mr-1.5 h-4 w-4 text-amber-400" /> Top Backers
</TabsTrigger>
<TabsTrigger value="collaboration" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Users className="mr-1.5 h-4 w-4 text-purple-400" /> Co-Creators (#722)
</TabsTrigger>
<TabsTrigger value="qa" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<MessageSquare className="mr-1.5 h-4 w-4 text-purple-400" /> Q&A (#791)
</TabsTrigger>
<TabsTrigger value="success" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Trophy className="mr-1.5 h-4 w-4 text-amber-400" /> Success Story
</TabsTrigger>
<TabsTrigger value="series" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Sparkles className="mr-1.5 h-4 w-4 text-amber-400" /> Series & Sequels
</TabsTrigger>
Expand Down Expand Up @@ -356,7 +364,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
</div>
</div>

<BackerCommunity campaignId={campaign.id} />
<BackerCommunity campaignId={campaign.id} canManage={isCreatorView} />
</div>
</div>
</TabsContent>
Expand All @@ -366,15 +374,28 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
<CampaignSponsorWall campaignId={campaign.id} campaignTitle={campaign.title} />
</TabsContent>

{/* Tab 3: Co-Creators Collaboration (#722) */}
{/* Tab 3: Top backers by amount, with creator featuring + privacy */}
<TabsContent value="backers">
<TopBackers
campaignId={campaign.id}
campaignTitle={campaign.title}
creatorAddress={campaign.creator}
viewerAddress={viewerAddress}
limit={TOP_BACKERS_LIMIT}
/>
</TabsContent>

{/* Tab 4: Co-Creators Collaboration (#722) */}
<TabsContent value="collaboration">
<CampaignCollaboration campaignId={campaign.id} campaignTitle={campaign.title} />
</TabsContent>

{/* Tab 4: Q&A Moderation (#791) */}
{/* Tab 5: Q&A Moderation (#791) */}
<TabsContent value="qa">
<CampaignQAModeration campaignId={campaign.id} campaignTitle={campaign.title} />
{/* Tab 4: Success Story */}
</TabsContent>

{/* Tab 6: Success Story */}
<TabsContent value="success" className="space-y-6">
<div className="relative overflow-hidden rounded-2xl border border-emerald-800/60 bg-gradient-to-br from-emerald-950/40 via-zinc-900 to-zinc-900 p-6 md:p-8">
<div className="flex items-center gap-2">
Expand All @@ -395,79 +416,26 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{campaign.successStory.backerTestimonials.map((testimonial) => (
<figure key={testimonial.name} className="rounded-xl border border-zinc-800 bg-zinc-950/60 p-4">
<blockquote className="text-sm text-zinc-300 leading-relaxed">"{testimonial.quote}"</blockquote>
<blockquote className="text-sm text-zinc-300 leading-relaxed">&ldquo;{testimonial.quote}&rdquo;</blockquote>
<figcaption className="mt-3 text-xs text-zinc-400">
<strong className="text-zinc-200">{testimonial.name}</strong> · {testimonial.location}
<strong className="text-zinc-200">{testimonial.name}</strong> &middot; {testimonial.location}
</figcaption>
</figure>
))}
</div>
</div>
</TabsContent>

{/* Tab 4: Series & Sequels */}
{/* Tab 7: Series & Sequels */}
<TabsContent value="series">
<CampaignSeries campaignId={campaign.id} campaignTitle={campaign.title} />
</TabsContent>

{/* Tab 5: Analytics for creators */}
{/* Tab 8: Analytics for creators */}
<TabsContent value="analytics">
<CampaignAnalyticsDashboard campaignId={campaign.id} campaignTitle={campaign.title} />
</TabsContent>
</Tabs>
{showInsuranceModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className="w-full max-w-md rounded-2xl border border-zinc-800 bg-zinc-950 p-6 shadow-2xl">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-amber-400 flex items-center gap-2">
<AlertTriangle className="h-5 w-5" /> Campaign Insurance Claim
</h3>
<button
type="button"
onClick={() => { setShowInsuranceModal(false); setClaimSubmitted(false); }}
className="text-xs font-semibold text-zinc-400 hover:text-zinc-200"
>
Close
</button>
</div>
{claimSubmitted ? (
<p className="mt-4 text-sm text-emerald-400">Claim submitted successfully.</p>
) : (
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
setClaimSubmitted(true);
}}
>
<textarea
required
rows={4}
placeholder="Describe how the campaign failed and upload proof below"
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<input
required
type="file"
className="w-full text-sm text-zinc-300 file:mr-3 file:rounded-md file:border-0 file:bg-amber-500/20 file:px-3 file:py-1.5 file:text-xs file:font-semibold file:text-amber-300 hover:file:bg-amber-500/30"
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => { setShowInsuranceModal(false); setClaimSubmitted(false); }}
>
Cancel
</Button>
<Button type="submit" size="sm" className="bg-amber-600 text-white hover:bg-amber-700">
Submit Claim
</Button>
</div>
</form>
)}
</div>
</div>
)}
</div>
);
}
Loading
Loading