From c2d7193a270f6208dc2fe44f75173480225e0169 Mon Sep 17 00:00:00 2001
From: sunny <5758289@qq.com>
Date: Tue, 12 May 2026 14:36:58 +0800
Subject: [PATCH] feat(frontend): add bounty discussion thread with nested
replies
---
frontend/src/api/comments.ts | 28 ++++
.../src/components/bounty/BountyDetail.tsx | 3 +
.../components/bounty/BountyDiscussion.tsx | 127 ++++++++++++++++++
frontend/src/hooks/useComments.ts | 24 ++++
4 files changed, 182 insertions(+)
create mode 100644 frontend/src/api/comments.ts
create mode 100644 frontend/src/components/bounty/BountyDiscussion.tsx
create mode 100644 frontend/src/hooks/useComments.ts
diff --git a/frontend/src/api/comments.ts b/frontend/src/api/comments.ts
new file mode 100644
index 000000000..9adff5f85
--- /dev/null
+++ b/frontend/src/api/comments.ts
@@ -0,0 +1,28 @@
+import { apiClient } from '../services/apiClient';
+
+export interface BountyComment {
+ id: string;
+ bounty_id: string;
+ parent_id?: string | null;
+ author: string;
+ message: string;
+ created_at: string;
+}
+
+interface CommentsResponse {
+ items: BountyComment[];
+}
+
+export async function listBountyComments(bountyId: string): Promise {
+ return apiClient(`/api/bounties/${bountyId}/comments`);
+}
+
+export async function createBountyComment(
+ bountyId: string,
+ payload: { message: string; parent_id?: string }
+): Promise {
+ return apiClient(`/api/bounties/${bountyId}/comments`, {
+ method: 'POST',
+ body: payload,
+ });
+}
diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx
index 65653fa8f..aedfe3036 100644
--- a/frontend/src/components/bounty/BountyDetail.tsx
+++ b/frontend/src/components/bounty/BountyDetail.tsx
@@ -6,6 +6,7 @@ import type { Bounty } from '../../types/bounty';
import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { useAuth } from '../../hooks/useAuth';
import { SubmissionForm } from './SubmissionForm';
+import { BountyDiscussion } from './BountyDiscussion';
import { fadeIn } from '../../lib/animations';
interface BountyDetailProps {
@@ -92,6 +93,8 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
+
+
{/* Submission form */}
{bounty.status === 'open' || bounty.status === 'funded' ? (
diff --git a/frontend/src/components/bounty/BountyDiscussion.tsx b/frontend/src/components/bounty/BountyDiscussion.tsx
new file mode 100644
index 000000000..0ee468041
--- /dev/null
+++ b/frontend/src/components/bounty/BountyDiscussion.tsx
@@ -0,0 +1,127 @@
+import React, { useMemo, useState } from 'react';
+import { MessageSquare, Reply, Send } from 'lucide-react';
+import type { Bounty } from '../../types/bounty';
+import { timeAgo } from '../../lib/utils';
+import { useAuth } from '../../hooks/useAuth';
+import { useBountyComments, useCreateBountyComment } from '../../hooks/useComments';
+
+const BLOCKED_TERMS = ['http://', 'https://', 'telegram', 'whatsapp', 'airdrop', 'wallet seed'];
+
+interface Node {
+ id: string;
+ parent_id?: string | null;
+ author: string;
+ message: string;
+ created_at: string;
+ children: Node[];
+}
+
+function buildTree(items: Node[]): Node[] {
+ const byId = new Map
();
+ const roots: Node[] = [];
+ items.forEach((i) => byId.set(i.id, { ...i, children: [] }));
+ byId.forEach((n) => {
+ if (n.parent_id && byId.has(n.parent_id)) byId.get(n.parent_id)!.children.push(n);
+ else roots.push(n);
+ });
+ return roots;
+}
+
+function CommentItem({ node, onReply }: { node: Node; onReply: (id: string) => void }) {
+ return (
+
+
+
{node.author}
+
{timeAgo(node.created_at)}
+
+
{node.message}
+
+ {node.children.length > 0 && (
+
+ {node.children.map((c) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function BountyDiscussion({ bounty }: { bounty: Bounty }) {
+ const { user, isAuthenticated } = useAuth();
+ const [message, setMessage] = useState('');
+ const [replyTo, setReplyTo] = useState(null);
+ const [localError, setLocalError] = useState(null);
+ const { data, isError } = useBountyComments(bounty.id);
+ const createComment = useCreateBountyComment(bounty.id);
+
+ const items = data?.items ?? [];
+ const tree = useMemo(() => buildTree(items as Node[]), [items]);
+
+ const onSubmit = async () => {
+ const text = message.trim();
+ if (!text) return;
+ if (BLOCKED_TERMS.some((k) => text.toLowerCase().includes(k))) {
+ setLocalError('Message blocked by anti-spam filter. Remove links or contact handles.');
+ return;
+ }
+ setLocalError(null);
+ await createComment.mutateAsync({ message: text, ...(replyTo ? { parent_id: replyTo } : {}) });
+ setMessage('');
+ setReplyTo(null);
+ };
+
+ return (
+
+
+
+
Discussion
+ live refresh: 5s
+
+
+ {isError &&
Comments API unavailable right now.
}
+
+ {tree.length === 0 ? (
+
No comments yet — start the discussion.
+ ) : (
+
+ {tree.map((n) => (
+
+ ))}
+
+ )}
+
+ {isAuthenticated ? (
+
+ {replyTo && (
+
+ Replying in thread ·
+
+ )}
+
+ ) : (
+
Sign in to join the thread.
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useComments.ts b/frontend/src/hooks/useComments.ts
new file mode 100644
index 000000000..4b8d6021f
--- /dev/null
+++ b/frontend/src/hooks/useComments.ts
@@ -0,0 +1,24 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { createBountyComment, listBountyComments } from '../api/comments';
+
+export function useBountyComments(bountyId: string | undefined) {
+ return useQuery({
+ queryKey: ['bounty-comments', bountyId],
+ queryFn: () => listBountyComments(bountyId!),
+ enabled: !!bountyId,
+ refetchInterval: 5000,
+ staleTime: 3000,
+ });
+}
+
+export function useCreateBountyComment(bountyId: string | undefined) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (payload: { message: string; parent_id?: string }) =>
+ createBountyComment(bountyId!, payload),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['bounty-comments', bountyId] });
+ },
+ });
+}