Skip to content

Commit 7300d97

Browse files
feat(campaign): backer community spaces — Discord/Telegram integration (#788)
Allow campaign creators to link Discord servers / Telegram groups to a campaign and backers to join them: - types/campaign-community.ts: space + membership types, platform URL validation (discord.gg / t.me patterns) - services/campaign-community.service.ts: in-memory singleton store (mirrors collaboration service), create/remove/join/leave/hasJoined - API routes: GET/POST /api/campaigns/[id]/community, POST/DELETE .../community/join - hooks/use-campaign-community.ts: state + mutations for the widget - BackerCommunity component: link-space form with platform picker and URL validation, join/leave/unlink actions per space card - Embedded on the campaign detail page (overview sidebar) Closes #788 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 73b199f commit 7300d97

7 files changed

Lines changed: 642 additions & 0 deletions

File tree

apps/web/src/app/(overview)/campaigns/[id]/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
1919
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
2020
import { CampaignSponsorWall } from "@/components/modules/campaign/sponsor-wall/CampaignSponsorWall";
2121
import { CampaignCollaboration } from "@/components/modules/campaign/collaboration/CampaignCollaboration";
22+
import { BackerCommunity } from "@/components/modules/campaign/community/BackerCommunity";
2223

2324
const translations = {
2425
es: {
@@ -186,6 +187,7 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
186187
</div>
187188

188189
{/* Main Content Tabs (Overview, Sponsor Wall #724, Co-Creators #722) */}
190+
{/* Backer community spaces (#788) render inside the overview sidebar. */}
189191
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
190192
<TabsList className="grid w-full grid-cols-3 bg-zinc-900 border border-zinc-800 p-1 rounded-xl">
191193
<TabsTrigger value="overview" className="text-xs font-semibold data-[state=active]:bg-purple-600 data-[state=active]:text-white">
@@ -244,6 +246,8 @@ export default function CampaignDetailPage({ params }: { params: Promise<{ id: s
244246
</div>
245247
</div>
246248
</div>
249+
250+
<BackerCommunity campaignId={campaign.id} />
247251
</div>
248252
</div>
249253
</TabsContent>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { communityService } from "@/services/campaign-community.service";
3+
4+
export async function POST(
5+
request: NextRequest,
6+
{ params }: { params: Promise<{ id: string }> }
7+
) {
8+
const { id } = await params;
9+
try {
10+
const body = await request.json();
11+
const { spaceId, memberAddress } = body;
12+
13+
if (!spaceId || !memberAddress) {
14+
return NextResponse.json({ error: "spaceId and memberAddress are required" }, { status: 400 });
15+
}
16+
17+
const result = communityService.joinSpace({ spaceId, memberAddress });
18+
if (!result.success) {
19+
return NextResponse.json({ error: result.error }, { status: 404 });
20+
}
21+
return NextResponse.json({ success: true, membership: result.membership }, { status: 201 });
22+
} catch (err) {
23+
return NextResponse.json({ error: "Failed to join community space" }, { status: 500 });
24+
}
25+
}
26+
27+
export async function DELETE(
28+
request: NextRequest,
29+
{ params }: { params: Promise<{ id: string }> }
30+
) {
31+
const { id } = await params;
32+
try {
33+
const { searchParams } = new URL(request.url);
34+
const spaceId = searchParams.get("spaceId");
35+
const memberAddress = searchParams.get("memberAddress");
36+
37+
if (!spaceId || !memberAddress) {
38+
return NextResponse.json({ error: "spaceId and memberAddress are required" }, { status: 400 });
39+
}
40+
41+
const left = communityService.leaveSpace(spaceId, memberAddress);
42+
if (!left) {
43+
return NextResponse.json({ error: "Membership not found" }, { status: 404 });
44+
}
45+
return NextResponse.json({ success: true });
46+
} catch (err) {
47+
return NextResponse.json({ error: "Failed to leave community space" }, { status: 500 });
48+
}
49+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { communityService } from "@/services/campaign-community.service";
3+
import { CreateCommunitySpaceInput } from "@/types/campaign-community";
4+
5+
export async function GET(
6+
request: NextRequest,
7+
{ params }: { params: Promise<{ id: string }> }
8+
) {
9+
const { id } = await params;
10+
const spaces = communityService.getSpaces(id);
11+
return NextResponse.json({ campaignId: id, spaces });
12+
}
13+
14+
export async function POST(
15+
request: NextRequest,
16+
{ params }: { params: Promise<{ id: string }> }
17+
) {
18+
const { id } = await params;
19+
try {
20+
const body = await request.json();
21+
const { platform, name, inviteUrl, description, visibility, linkedBy, memberCount } = body;
22+
23+
const input: CreateCommunitySpaceInput = {
24+
campaignId: id,
25+
platform,
26+
name,
27+
inviteUrl,
28+
description,
29+
visibility,
30+
linkedBy: linkedBy || "UNKNOWN",
31+
memberCount,
32+
};
33+
34+
const space = communityService.createSpace(input);
35+
return NextResponse.json({ success: true, space }, { status: 201 });
36+
} catch (err) {
37+
const message = err instanceof Error ? err.message : "Failed to create community space";
38+
return NextResponse.json({ error: message }, { status: 400 });
39+
}
40+
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"use client";
2+
3+
import React, { useState } from "react";
4+
import {
5+
MessageCircle,
6+
Users,
7+
ExternalLink,
8+
Trash2,
9+
Plus,
10+
} from "lucide-react";
11+
import { Button } from "@/components/ui/button";
12+
import { Badge } from "@/components/ui/badge";
13+
import { Input } from "@/components/ui/input";
14+
import {
15+
CommunityPlatform,
16+
CommunitySpace,
17+
COMMUNITY_PLATFORMS,
18+
} from "@/types/campaign-community";
19+
import { useCampaignCommunity } from "@/hooks/use-campaign-community";
20+
21+
export interface BackerCommunityProps {
22+
campaignId: string;
23+
currentUserAddress?: string;
24+
canManage?: bool;
25+
}
26+
27+
const PLATFORM_ICON: Record<CommunityPlatform, React.ReactNode> = {
28+
DISCORD: <MessageCircle className="h-4 w-4 text-indigo-400" />,
29+
TELEGRAM: <MessageCircle className="h-4 w-4 text-sky-400" />,
30+
};
31+
32+
export function BackerCommunity({
33+
campaignId,
34+
currentUserAddress = "GD6W...X892",
35+
canManage = true,
36+
}: BackerCommunityProps) {
37+
const community = useCampaignCommunity({ campaignId, currentUserAddress });
38+
const [adding, setAdding] = useState(false);
39+
const [platform, setPlatform] = useState<CommunityPlatform>("DISCORD");
40+
const [name, setName] = useState("");
41+
const [inviteUrl, setInviteUrl] = useState("");
42+
const [error, setError] = useState<string | null>(null);
43+
44+
const startAdd = () => {
45+
setAdding(true);
46+
setName("");
47+
setInviteUrl("");
48+
setError(null);
49+
};
50+
51+
const submit = () => {
52+
setError(null);
53+
const result = community.createSpace({
54+
platform,
55+
name,
56+
inviteUrl,
57+
visibility: "BACKERS_ONLY",
58+
});
59+
if (!result.ok) {
60+
setError(result.error);
61+
return;
62+
}
63+
setAdding(false);
64+
};
65+
66+
return (
67+
<section className="rounded-xl border border-zinc-800 bg-zinc-900/60 p-6 space-y-4">
68+
<div className="flex items-center justify-between">
69+
<div>
70+
<h3 className="text-lg font-bold text-zinc-100 flex items-center gap-2">
71+
<Users className="h-5 w-5 text-purple-400" />
72+
Backer Community
73+
</h3>
74+
<p className="text-xs text-zinc-400 mt-0.5">
75+
Discord servers and Telegram groups for backers of this campaign.
76+
</p>
77+
</div>
78+
{canManage && !adding && (
79+
<Button size="sm" variant="outline" onClick={startAdd}
80+
className="border-purple-600/40 text-purple-300 hover:bg-purple-950/40 text-xs">
81+
<Plus className="mr-1.5 h-3.5 w-3.5" /> Link space
82+
</Button>
83+
)}
84+
</div>
85+
86+
{adding && (
87+
<div className="rounded-lg border border-zinc-700 bg-zinc-950/80 p-4 space-y-3">
88+
<div className="flex gap-2">
89+
{(Object.keys(COMMUNITY_PLATFORMS) as CommunityPlatform[]).map((p) => (
90+
<button
91+
key={p}
92+
type="button"
93+
onClick={() => setPlatform(p)}
94+
className={`px-3 py-1.5 rounded-md text-xs font-semibold border ${
95+
platform === p
96+
? "border-purple-500 bg-purple-950/60 text-purple-200"
97+
: "border-zinc-700 bg-zinc-900 text-zinc-400"
98+
}`}
99+
>
100+
{COMMUNITY_PLATFORMS[p].label}
101+
</button>
102+
))}
103+
</div>
104+
<Input
105+
placeholder="Space name (e.g. Save the Amazon — Discord)"
106+
value={name}
107+
onChange={(e) => setName(e.target.value)}
108+
className="text-xs"
109+
/>
110+
<Input
111+
placeholder={COMMUNITY_PLATFORMS[platform].urlHint}
112+
value={inviteUrl}
113+
onChange={(e) => setInviteUrl(e.target.value)}
114+
className="text-xs font-mono"
115+
/>
116+
{error != null && <p className="text-xs text-rose-400">{error}</p>}
117+
<div className="flex gap-2 justify-end">
118+
<Button size="sm" variant="ghost" onClick={() => setAdding(false)}
119+
className="text-xs text-zinc-400">
120+
Cancel
121+
</Button>
122+
<Button size="sm" onClick={submit}
123+
className="bg-purple-600 hover:bg-purple-700 text-white text-xs">
124+
Link space
125+
</Button>
126+
</div>
127+
</div>
128+
)}
129+
130+
{community.spaces.isEmpty ? (
131+
<p className="text-xs text-zinc-500 py-2">
132+
No community spaces linked yet.
133+
</p>
134+
) : (
135+
<div className="space-y-2.5">
136+
{community.spaces.map((space: CommunitySpace) => (
137+
<CommunitySpaceCard
138+
key={space.id}
139+
space={space}
140+
joined={community.hasJoined(space.id)}
141+
canManage={canManage}
142+
onJoin={() => community.joinSpace(space.id)}
143+
onLeave={() => community.leaveSpace(space.id)}
144+
onRemove={() => community.removeSpace(space.id)}
145+
/>
146+
))}
147+
</div>
148+
)}
149+
</section>
150+
);
151+
}
152+
153+
interface CommunitySpaceCardProps {
154+
space: CommunitySpace;
155+
joined: boolean;
156+
canManage: boolean;
157+
onJoin: () => void;
158+
onLeave: () => void;
159+
onRemove: () => void;
160+
}
161+
162+
function CommunitySpaceCard({
163+
space,
164+
joined,
165+
canManage,
166+
onJoin,
167+
onLeave,
168+
onRemove,
169+
}: CommunitySpaceCardProps) {
170+
return (
171+
<div className="flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-950/60 p-3.5">
172+
<div className="flex items-center gap-3 min-w-0">
173+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-900 border border-zinc-800">
174+
{PLATFORM_ICON[space.platform]}
175+
</div>
176+
<div className="min-w-0">
177+
<div className="flex items-center gap-2">
178+
<span className="font-semibold text-zinc-100 text-sm truncate">
179+
{space.name}
180+
</span>
181+
<Badge variant="outline" className="text-[10px] text-zinc-400">
182+
{COMMUNITY_PLATFORMS[space.platform].label}
183+
</Badge>
184+
{space.visibility === "BACKERS_ONLY" && (
185+
<Badge variant="outline" className="text-[10px] text-amber-400">
186+
Backers only
187+
</Badge>
188+
)}
189+
</div>
190+
{space.description != null && (
191+
<p className="text-[11px] text-zinc-500 mt-0.5 truncate">
192+
{space.description}
193+
</p>
194+
)}
195+
</div>
196+
</div>
197+
198+
<div className="flex items-center gap-2 shrink-0">
199+
{space.memberCount != null && (
200+
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
201+
<Users className="h-3 w-3" /> {space.memberCount}
202+
</span>
203+
)}
204+
<Button
205+
size="sm"
206+
variant={joined ? "outline" : "default"}
207+
onClick={joined ? onLeave : onJoin}
208+
className={joined
209+
? "border-zinc-700 text-zinc-300 text-xs"
210+
: "bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-xs font-semibold"}
211+
>
212+
{joined ? "Joined" : "Join"}
213+
</Button>
214+
<Button
215+
size="sm"
216+
variant="ghost"
217+
onClick={() => window.open(space.inviteUrl, "_blank")}
218+
className="text-zinc-500 hover:text-zinc-200"
219+
title="Open invite"
220+
>
221+
<ExternalLink className="h-4 w-4" />
222+
</Button>
223+
{canManage && (
224+
<Button
225+
size="sm"
226+
variant="ghost"
227+
onClick={onRemove}
228+
className="text-zinc-500 hover:text-rose-400"
229+
title="Unlink space"
230+
>
231+
<Trash2 className="h-4 w-4" />
232+
</Button>
233+
)}
234+
</div>
235+
</div>
236+
);
237+
}

0 commit comments

Comments
 (0)