diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index 2ef4cf7..a870603 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -1,55 +1,96 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; -import { Submission } from '@/types/participation'; +import { NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import { Submission } from "@/types/participation"; +import { submissionFormSchema } from "@/components/bounty/forms/schemas"; +import { getCurrentUser } from "@/lib/server-auth"; const generateId = () => crypto.randomUUID(); export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } + request: Request, + { params }: { params: Promise<{ id: string }> }, ) { - const { id: bountyId } = await params; + const { id: bountyId } = await params; - try { - const body = await request.json(); - const { contributorId, content } = body; + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const contributorId = user.id; - if (!contributorId || !content) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } + const body = await request.json(); + const { contributorId: _clientContributorId, ...formData } = body; - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } + const parsed = submissionFormSchema.safeParse(formData); + if (!parsed.success) { + const fieldErrors = parsed.error.flatten().fieldErrors; + return NextResponse.json( + { error: "Validation failed", fieldErrors }, + { status: 400 }, + ); + } - const allowedModels = ['single-claim', 'competition', 'multi-winner', 'application']; - if (!allowedModels.includes(bounty.claimingModel)) { - return NextResponse.json({ error: 'Submission not allowed for this bounty type' }, { status: 400 }); - } + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } - const existingSubmission = BountyStore.getSubmissionsByBounty(bountyId).find( - s => s.contributorId === contributorId - ); + if (bounty.status !== "open") { + return NextResponse.json( + { error: "Submissions are not accepted for this bounty" }, + { status: 400 }, + ); + } - if (existingSubmission) { - return NextResponse.json({ error: 'Duplicate submission' }, { status: 409 }); - } + const allowedModels = [ + "single-claim", + "competition", + "multi-winner", + "application", + ]; + if (!allowedModels.includes(bounty.claimingModel)) { + return NextResponse.json( + { error: "Submission not allowed for this bounty type" }, + { status: 400 }, + ); + } - const submission: Submission = { - id: generateId(), - bountyId, - contributorId, - content, - status: 'pending', - submittedAt: new Date().toISOString(), - }; + const existingSubmission = BountyStore.getSubmissionsByBounty( + bountyId, + ).find((s) => s.contributorId === contributorId); - BountyStore.addSubmission(submission); + if (existingSubmission) { + return NextResponse.json( + { error: "Duplicate submission" }, + { status: 409 }, + ); + } - return NextResponse.json({ success: true, data: submission }); + const { explanation, walletAddress, githubUrl, demoUrl, attachments } = + parsed.data; - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } + const submission: Submission = { + id: generateId(), + bountyId, + contributorId, + content: explanation, + explanation, + walletAddress, + githubUrl: githubUrl || undefined, + demoUrl: demoUrl || undefined, + attachments: attachments?.length ? attachments : undefined, + status: "pending", + submittedAt: new Date().toISOString(), + }; + + BountyStore.addSubmission(submission); + + return NextResponse.json({ success: true, data: submission }); + } catch { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/app/api/reputation/[userId]/completion-history/route.ts b/app/api/reputation/[userId]/completion-history/route.ts new file mode 100644 index 0000000..d98b87b --- /dev/null +++ b/app/api/reputation/[userId]/completion-history/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { BountyStore } from "@/lib/store"; +import type { BountyCompletionRecord } from "@/types/reputation"; +import type { Bounty } from "@/types/bounty"; + +const DIFFICULTY_MAP = { + beginner: "BEGINNER" as const, + intermediate: "INTERMEDIATE" as const, + advanced: "ADVANCED" as const, +}; + +function bountyToCompletionRecord( + bounty: Bounty, + index: number, +): BountyCompletionRecord { + const difficulty = bounty.difficulty + ? (DIFFICULTY_MAP[bounty.difficulty] ?? "BEGINNER") + : "BEGINNER"; + const reward = bounty.rewardAmount ?? 0; + const claimedAt = bounty.claimedAt ?? bounty.createdAt; + const completedAt = bounty.updatedAt; + + return { + id: `completion-${bounty.id}-${index}`, + bountyId: bounty.id, + bountyTitle: bounty.issueTitle, + projectName: bounty.projectName, + projectLogoUrl: bounty.projectLogoUrl, + difficulty, + rewardAmount: reward, + rewardCurrency: bounty.rewardCurrency, + claimedAt, + completedAt, + completionTimeHours: 0, + maintainerRating: null, + maintainerFeedback: null, + pointsEarned: reward, + }; +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ userId: string }> }, +) { + try { + const { userId } = await context.params; + const { searchParams } = new URL(request.url); + const limit = Math.min( + Math.max(1, Number(searchParams.get("limit")) || 50), + 100, + ); + const offset = Math.max(0, Number(searchParams.get("offset")) || 0); + + const bounties = BountyStore.getBounties(); + const completed = bounties.filter( + (b) => b.status === "closed" && b.claimedBy === userId, + ); + + const totalCount = completed.length; + const paginated = completed.slice(offset, offset + limit); + const records: BountyCompletionRecord[] = paginated.map((b, i) => + bountyToCompletionRecord(b, offset + i), + ); + + return NextResponse.json({ + records, + totalCount, + hasMore: offset + records.length < totalCount, + }); + } catch (error) { + console.error("Error fetching completion history:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/transparency/payouts/route.ts b/app/api/transparency/payouts/route.ts index d74dc48..4ef5581 100644 --- a/app/api/transparency/payouts/route.ts +++ b/app/api/transparency/payouts/route.ts @@ -1,19 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const limit = Number(searchParams.get("limit")) || 10; + const { searchParams } = new URL(request.url); + const limit = Number(searchParams.get("limit")) || 10; - // TODO: Replace with real DB queries when backend is ready - const payouts: { - id: string; - contributorName: string; - contributorAvatar: string | null; - amount: number; - currency: string; - projectName: string; - paidAt: string; - }[] = []; + // TODO: Replace with real DB queries when backend is ready + const payouts: { + id: string; + contributorName: string; + contributorAvatar: string | null; + amount: number; + currency: string; + projectName: string; + paidAt: string; + }[] = []; - return NextResponse.json(payouts.slice(0, limit)); -} \ No newline at end of file + return NextResponse.json(payouts.slice(0, limit)); +} diff --git a/app/api/transparency/stats/route.ts b/app/api/transparency/stats/route.ts index baf83b3..2c2e815 100644 --- a/app/api/transparency/stats/route.ts +++ b/app/api/transparency/stats/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server"; export async function GET() { - // TODO: Replace with real DB queries when backend is ready - const stats = { - totalFundsDistributed: 0, - totalContributorsPaid: 0, - totalProjectsFunded: 0, - averagePayoutTimeDays: 0, - }; + // TODO: Replace with real DB queries when backend is ready + const stats = { + totalFundsDistributed: 0, + totalContributorsPaid: 0, + totalProjectsFunded: 0, + averagePayoutTimeDays: 0, + }; - return NextResponse.json(stats); -} \ No newline at end of file + return NextResponse.json(stats); +} diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 743a525..00ad8ba 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -1,6 +1,9 @@ "use client"; -import { useContributorReputation } from "@/hooks/use-reputation"; +import { + useContributorReputation, + useCompletionHistory, +} from "@/hooks/use-reputation"; import { useBounties } from "@/hooks/use-bounties"; import { ReputationCard } from "@/components/reputation/reputation-card"; import { CompletionHistory } from "@/components/reputation/completion-history"; @@ -14,184 +17,186 @@ import { useParams } from "next/navigation"; import { useMemo } from "react"; export default function ProfilePage() { - const params = useParams(); - const userId = params.userId as string; - const { data: reputation, isLoading, error } = useContributorReputation(userId); - const { data: bountyResponse } = useBounties(); - - const MAX_MOCK_HISTORY = 50; - - const mockHistory = useMemo(() => { - if (!reputation) return []; - const count = Math.min(reputation.stats.totalCompleted ?? 0, MAX_MOCK_HISTORY); - return Array(count).fill(null).map((_, i) => ({ - id: `bounty-${i}`, - bountyId: `b-${i}`, - bountyTitle: `Implemented feature #${100 + i}`, - projectName: "Drips Protocol", - projectLogoUrl: null, - difficulty: ["BEGINNER", "INTERMEDIATE", "ADVANCED"][i % 3] as "BEGINNER" | "INTERMEDIATE" | "ADVANCED", - rewardAmount: 500, - rewardCurrency: "USDC", - claimedAt: "2023-01-01T00:00:00Z", - completedAt: "2024-01-15T12:00:00Z", - completionTimeHours: 48, - maintainerRating: 5, - maintainerFeedback: "Great work!", - pointsEarned: 150 - })); - }, [reputation]); - - const myClaims = useMemo(() => { - const bounties = bountyResponse?.data ?? []; - - return bounties - .filter((bounty) => bounty.claimedBy === userId) - .map((bounty) => { - let status = "active"; - - if (bounty.status === "closed") { - status = "completed"; - } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { - const claimExpiry = new Date(bounty.claimExpiresAt); - if (!Number.isNaN(claimExpiry.getTime()) && claimExpiry < new Date()) { - status = "in-review"; - } - } - - return { - bountyId: bounty.id, - title: bounty.issueTitle, - status, - rewardAmount: bounty.rewardAmount ?? undefined, - }; - }); - }, [bountyResponse?.data, userId]); - - if (isLoading) { - return ( -
- -
- - -
-
- ); - } - - if (error) { - // Check if it's a 404 (Not Found) - const apiError = error as { status?: number; message?: string }; - const isNotFound = apiError?.status === 404 || apiError?.message?.includes("404"); - - if (isNotFound) { - return ( -
- -

Profile Not Found

-

- We could not find a reputation profile for this user. -

- -
- ); + const params = useParams(); + const userId = params.userId as string; + const { + data: reputation, + isLoading, + error, + } = useContributorReputation(userId); + const { data: bountyResponse } = useBounties(); + const { data: completionData, isLoading: completionLoading } = + useCompletionHistory(userId); + + const completionRecords = completionData?.records ?? []; + + const myClaims = useMemo(() => { + const bounties = bountyResponse?.data ?? []; + + return bounties + .filter((bounty) => bounty.claimedBy === userId) + .map((bounty) => { + let status = "active"; + + if (bounty.status === "closed") { + status = "completed"; + } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { + const claimExpiry = new Date(bounty.claimExpiresAt); + if ( + !Number.isNaN(claimExpiry.getTime()) && + claimExpiry < new Date() + ) { + status = "expired"; + } } - // Generic Error - return ( -
- -

Something went wrong

-

- We encountered an error while loading the profile. -

- -
- ); - } + return { + bountyId: bounty.id, + title: bounty.issueTitle, + status, + rewardAmount: bounty.rewardAmount ?? undefined, + }; + }); + }, [bountyResponse?.data, userId]); - if (!reputation) { - return ( -
- -

Profile Not Found

-

- We could not find a reputation profile for this user. -

- -
- ); + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (error) { + // Check if it's a 404 (Not Found) + const apiError = error as { status?: number; message?: string }; + const isNotFound = + apiError?.status === 404 || apiError?.message?.includes("404"); + + if (isNotFound) { + return ( +
+ +

Profile Not Found

+

+ We could not find a reputation profile for this user. +

+ +
+ ); } + // Generic Error return ( -
- - -
- {/* Left Sidebar: Reputation Card */} -
- - - {/* Additional Sidebar Info could go here */} -
- - {/* Main Content: Activity & History */} -
- - - - Bounty History - - - Analytics - - - My Claims - - - - -

Activity History

- -
- - -
- Detailed analytics coming soon. -
-
- - -

My Claims

- -
-
-
-
-
+
+ +

Something went wrong

+

+ We encountered an error while loading the profile. +

+ +
+ ); + } + + if (!reputation) { + return ( +
+ +

Profile Not Found

+

+ We could not find a reputation profile for this user. +

+ +
); + } + + return ( +
+ + +
+ {/* Left Sidebar: Reputation Card */} +
+ + + {/* Additional Sidebar Info could go here */} +
+ + {/* Main Content: Activity & History */} +
+ + + + Bounty History + + + Analytics + + + My Claims + + + + +

Activity History

+ {completionLoading ? ( + + ) : ( + 0 + ? `Showing the last ${completionRecords.length} completed bounties.` + : undefined + } + /> + )} +
+ + +
+ Detailed analytics coming soon. +
+
+ + +

My Claims

+ +
+
+
+
+
+ ); } diff --git a/app/transparency/page.tsx b/app/transparency/page.tsx index d75abd6..39dc2f6 100644 --- a/app/transparency/page.tsx +++ b/app/transparency/page.tsx @@ -1,7 +1,14 @@ "use client"; +import type { ElementType } from "react"; import { usePlatformStats, useRecentPayouts } from "@/hooks/use-transparency"; -import { AlertCircle, DollarSign, Users, FolderOpen, Clock } from "lucide-react"; +import { + AlertCircle, + DollarSign, + Users, + FolderOpen, + Clock, +} from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -10,221 +17,235 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import type { RecentPayout } from "@/lib/api/transparency"; -// Stat Card +// Stat Card function StatCard({ - title, - value, - icon: Icon, - isLoading, + title, + value, + icon: Icon, + isLoading, }: { - title: string; - value: string; - icon: React.ElementType; - isLoading: boolean; + title: string; + value: string; + icon: ElementType; + isLoading: boolean; }) { - return ( - - - - {title} - - - - - {isLoading ? ( - - ) : ( -

{value}

- )} -
-
- ); + return ( + + + + {title} + + + + + {isLoading ? ( + + ) : ( +

{value}

+ )} +
+
+ ); } -// Payout Row +// Payout Row function PayoutRow({ payout }: { payout: RecentPayout }) { - return ( -
-
- - - - {payout.contributorName.slice(0, 2).toUpperCase()} - - -
-

- {payout.contributorName} -

-

{payout.projectName}

-
-
-
- - {payout.amount.toLocaleString()} {payout.currency} - - - {new Date(payout.paidAt).toLocaleDateString()} - -
+ return ( +
+
+ + + + {payout.contributorName.slice(0, 2).toUpperCase()} + + +
+

+ {payout.contributorName} +

+

{payout.projectName}

- ); +
+
+ + {payout.amount.toLocaleString()} {payout.currency} + + + {new Date(payout.paidAt).toLocaleDateString()} + +
+
+ ); } -// Page +// Page export default function TransparencyPage() { - const { - data: stats, - isLoading: statsLoading, - isError: statsError, - error: statsErr, - refetch: refetchStats, - } = usePlatformStats(); + const { + data: stats, + isLoading: statsLoading, + isError: statsError, + error: statsErr, + refetch: refetchStats, + } = usePlatformStats(); - const { - data: payouts, - isLoading: payoutsLoading, - isError: payoutsError, - refetch: refetchPayouts, - } = useRecentPayouts(10); + const { + data: payouts, + isLoading: payoutsLoading, + isError: payoutsError, + refetch: refetchPayouts, + } = useRecentPayouts(10); - const statCards = [ - { - title: "Total Funds Distributed", - value: stats - ? `$${stats.totalFundsDistributed.toLocaleString()}` - : "$0", - icon: DollarSign, - }, - { - title: "Contributors Paid", - value: stats ? stats.totalContributorsPaid.toLocaleString() : "0", - icon: Users, - }, - { - title: "Projects Funded", - value: stats ? stats.totalProjectsFunded.toLocaleString() : "0", - icon: FolderOpen, - }, - { - title: "Avg. Payout Time", - value: stats ? `${stats.averagePayoutTimeDays} days` : "0 days", - icon: Clock, - }, - ]; + const statCards = [ + { + title: "Total Funds Distributed", + value: statsError + ? "—" + : stats + ? `$${stats.totalFundsDistributed.toLocaleString()}` + : "$0", + icon: DollarSign, + }, + { + title: "Contributors Paid", + value: statsError + ? "—" + : stats + ? stats.totalContributorsPaid.toLocaleString() + : "0", + icon: Users, + }, + { + title: "Projects Funded", + value: statsError + ? "—" + : stats + ? stats.totalProjectsFunded.toLocaleString() + : "0", + icon: FolderOpen, + }, + { + title: "Avg. Payout Time", + value: statsError + ? "—" + : stats + ? `${stats.averagePayoutTimeDays} days` + : "0 days", + icon: Clock, + }, + ]; - return ( -
- {/* Hero Header */} -
-
-

- Transparency -

-

- A real-time look at platform funding activity, contributor payouts, - and ecosystem growth. -

-
-
- -
+ return ( +
+ {/* Hero Header */} +
+
+

+ Transparency +

+

+ A real-time look at platform funding activity, contributor payouts, + and ecosystem growth. +

+
+
- {/* Stats Error */} - {statsError && ( - - - Error - -

- Failed to load platform stats.{" "} - {(statsErr as Error)?.message} -

- -
-
- )} +
+ {/* Stats Error */} + {statsError && ( + + + Error + +

+ Failed to load platform stats. {(statsErr as Error)?.message} +

+ +
+
+ )} - {/* Stats Grid */} -
-

- Platform Overview -

-
- {statCards.map((card) => ( - - ))} -
-
+ {/* Stats Grid - hidden when error so zeros aren't shown */} + {!statsError && ( +
+

+ Platform Overview +

+
+ {statCards.map((card) => ( + + ))} +
+
+ )} - {/* Recent Payouts */} -
-

- Recent Payouts -

+ {/* Recent Payouts */} +
+

+ Recent Payouts +

- {payoutsError ? ( - - - Error - -

Failed to load recent payouts.

- -
-
- ) : ( - - - {payoutsLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - -
- -
- ))} -
- ) : !payouts || payouts.length === 0 ? ( -

- No payouts recorded yet. -

- ) : ( - payouts.map((payout) => ( - - )) - )} -
-
- )} -
-
-
- ); -} \ No newline at end of file + {payoutsError ? ( + + + Error + +

Failed to load recent payouts.

+ +
+
+ ) : ( + + + {payoutsLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : !payouts || payouts.length === 0 ? ( +

+ No payouts recorded yet. +

+ ) : ( + payouts.map((payout) => ( + + )) + )} +
+
+ )} + +
+
+ ); +} diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index a26cf5d..730f5a4 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -8,9 +8,11 @@ import { Separator } from "@/components/ui/separator"; import type { Bounty } from "@/lib/api"; import { DifficultyBadge, StatusBadge } from "./bounty-badges"; import { CLAIMING_MODEL_CONFIG } from "@/lib/bounty-config"; +import { SubmissionDialog } from "./submission-dialog"; export function SidebarCTA({ bounty }: { bounty: Bounty }) { const [copied, setCopied] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); const canAct = bounty.status === "open"; const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; const ClaimIcon = claimCfg.icon; @@ -102,14 +104,18 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) { className="w-full h-11 font-bold tracking-wide" disabled={!canAct} size="lg" - onClick={() => - canAct && - window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer") - } + onClick={() => canAct && setDialogOpen(true)} > {ctaLabel()} + + {!canAct && (

@@ -169,8 +175,8 @@ export function ClaimModelInfo({ } export function MobileCTA({ bounty }: { bounty: Bounty }) { + const [dialogOpen, setDialogOpen] = useState(false); const canAct = bounty.status === "open"; - // const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; const label = () => { if (!canAct) @@ -193,13 +199,17 @@ export function MobileCTA({ bounty }: { bounty: Bounty }) { className="w-full h-11 font-bold tracking-wide" disabled={!canAct} size="lg" - onClick={() => - canAct && - window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer") - } + onClick={() => canAct && setDialogOpen(true)} > {label()} + +

); } diff --git a/components/bounty-detail/submission-dialog.tsx b/components/bounty-detail/submission-dialog.tsx new file mode 100644 index 0000000..bb6c007 --- /dev/null +++ b/components/bounty-detail/submission-dialog.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { Plus, Trash2, Save, Send, Loader2 } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { + submissionFormSchema, + type SubmissionFormValue, +} from "@/components/bounty/forms/schemas"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { bountiesApi } from "@/lib/api/bounties"; +import { authClient } from "@/lib/auth-client"; +import { mockWalletInfo } from "@/lib/mock-wallet"; + +interface SubmissionDialogProps { + bountyId: string; + bountyTitle: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const getBaseDefaults = (): SubmissionFormValue => ({ + githubUrl: "", + demoUrl: "", + explanation: "", + attachments: [], + walletAddress: mockWalletInfo.address, +}); + +export function SubmissionDialog({ + bountyId, + bountyTitle, + open, + onOpenChange, +}: SubmissionDialogProps) { + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const timeoutRef = useRef | null>(null); + const { data: session } = authClient.useSession(); + const storageKey = `submission-draft-${bountyId}`; + const [draft, setDraft] = useLocalStorage( + storageKey, + null, + ); + + const baseDefaults = getBaseDefaults(); + + const form = useForm({ + resolver: zodResolver(submissionFormSchema), + defaultValues: draft + ? { ...draft, walletAddress: baseDefaults.walletAddress } + : baseDefaults, + }); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "attachments" as never, + }); + + useEffect(() => { + if (open && draft) { + form.reset({ ...draft, walletAddress: baseDefaults.walletAddress }); + } else if (open) { + form.reset(baseDefaults); + } + if (open) { + setSubmitted(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const saveDraft = useCallback(() => { + const values = form.getValues(); + setDraft(values); + toast.success("Draft saved"); + onOpenChange(false); + }, [form, setDraft, onOpenChange]); + + const clearDraft = useCallback(() => { + setDraft(null); + }, [setDraft]); + + const onSubmit = async (data: SubmissionFormValue) => { + const contributorId = session?.user?.id; + if (!contributorId) { + toast.error("You must be signed in to submit."); + return; + } + + setSubmitting(true); + try { + const payload = { + ...data, + githubUrl: data.githubUrl || undefined, + demoUrl: data.demoUrl || undefined, + attachments: data.attachments?.filter(Boolean), + contributorId, + }; + + await bountiesApi.submit(bountyId, payload); + + clearDraft(); + form.reset(baseDefaults); + setSubmitted(true); + toast.success("Submission sent successfully!"); + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + onOpenChange(false); + setSubmitted(false); + }, 2000); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to submit. Please try again."; + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + if (submitted) { + return ( + + +
+
+ +
+

+ Submission Sent! +

+

+ Your work for "{bountyTitle}" has been submitted and is + pending review. +

+
+
+
+ ); + } + + return ( + + + + Submit Work + + Submit your work for "{bountyTitle}" + + + +
+ + ( + + + Wallet Address * + + + + + + Your connected wallet (rewards will be sent here) + + + + )} + /> + + ( + + + Explanation * + + +