From b9eff96e4d68d704c856462cfd19149f4edef123 Mon Sep 17 00:00:00 2001 From: od-hunter Date: Sat, 21 Feb 2026 11:25:27 +0100 Subject: [PATCH] feat: add structured submission form with draft saving and success state --- app/api/bounties/[id]/submit/route.ts | 112 ++- .../bounty-detail-sidebar-cta.tsx | 420 +++++----- .../bounty-detail/submission-dialog.tsx | 321 ++++++++ components/bounty/forms/schemas.ts | 73 +- lib/api/bounties.ts | 162 ++-- package-lock.json | 45 +- pnpm-lock.yaml | 772 +++--------------- types/participation.ts | 107 +-- 8 files changed, 965 insertions(+), 1047 deletions(-) create mode 100644 components/bounty-detail/submission-dialog.tsx diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index 2ef4cf7..1394af1 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -1,55 +1,89 @@ -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"; 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 body = await request.json(); + const { contributorId, ...formData } = body; - if (!contributorId || !content) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } + if (!contributorId) { + return NextResponse.json( + { error: "Missing contributor ID" }, + { status: 400 }, + ); + } - 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 - ); + 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 }, + ); + } - if (existingSubmission) { - return NextResponse.json({ error: 'Duplicate submission' }, { status: 409 }); - } + const existingSubmission = BountyStore.getSubmissionsByBounty( + bountyId, + ).find((s) => s.contributorId === contributorId); - const submission: Submission = { - id: generateId(), - bountyId, - contributorId, - content, - status: 'pending', - submittedAt: new Date().toISOString(), - }; + if (existingSubmission) { + return NextResponse.json( + { error: "Duplicate submission" }, + { status: 409 }, + ); + } - BountyStore.addSubmission(submission); + const { explanation, walletAddress, githubUrl, demoUrl, attachments } = + parsed.data; - return NextResponse.json({ success: true, data: submission }); + 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(), + }; - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } + BountyStore.addSubmission(submission); + + return NextResponse.json({ success: true, data: submission }); + } catch { + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index a468597..730f5a4 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -1,205 +1,215 @@ -"use client"; - -import { useState } from "react"; -import { Github, Copy, Check, AlertCircle, Clock } from "lucide-react"; -import { Button } from "@/components/ui/button"; -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"; - -export function SidebarCTA({ bounty }: { bounty: Bounty }) { - const [copied, setCopied] = useState(false); - const canAct = bounty.status === "open"; - const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; - const ClaimIcon = claimCfg.icon; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(window.location.href); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // clipboard write failed (e.g. non-HTTPS, permission denied) - } - }; - - const ctaLabel = () => { - if (!canAct) - return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; - switch (bounty.claimingModel) { - case "single-claim": - return "Claim Bounty"; - case "application": - return "Apply Now"; - case "competition": - return "Submit Entry"; - case "multi-winner": - return "Submit Work"; - } - }; - - return ( -
- {/* Reward */} -
- - Reward - -
-

- {bounty.rewardAmount != null - ? `$${bounty.rewardAmount.toLocaleString()}` - : "TBD"} -

-

- {bounty.rewardCurrency} -

-
-
- - - - {/* Meta */} -
-
- Status - -
-
- Model - - - {claimCfg.label} - -
- {bounty.difficulty && ( -
- Difficulty - -
- )} - {bounty.submissionsEndDate && ( -
- Deadline - - - {new Date(bounty.submissionsEndDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - -
- )} -
- - - - {/* CTA */} - - - {!canAct && ( -

- - {bounty.status === "claimed" - ? "A contributor has already claimed this bounty." - : "This bounty is no longer accepting submissions."} -

- )} - - {/* GitHub */} - - - View on GitHub - - - {/* Copy link */} - -
- ); -} - -export function ClaimModelInfo({ - claimingModel, -}: { - claimingModel: Bounty["claimingModel"]; -}) { - return ( -
-

- Claim Model -

-

- {CLAIMING_MODEL_CONFIG[claimingModel].description} -

-
- ); -} - -export function MobileCTA({ bounty }: { bounty: Bounty }) { - const canAct = bounty.status === "open"; - // const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; - - const label = () => { - if (!canAct) - return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; - switch (bounty.claimingModel) { - case "single-claim": - return "Claim Bounty"; - case "application": - return "Apply Now"; - case "competition": - return "Submit Entry"; - case "multi-winner": - return "Submit Work"; - } - }; - - return ( -
- -
- ); -} +"use client"; + +import { useState } from "react"; +import { Github, Copy, Check, AlertCircle, Clock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +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; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard write failed (e.g. non-HTTPS, permission denied) + } + }; + + const ctaLabel = () => { + if (!canAct) + return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; + switch (bounty.claimingModel) { + case "single-claim": + return "Claim Bounty"; + case "application": + return "Apply Now"; + case "competition": + return "Submit Entry"; + case "multi-winner": + return "Submit Work"; + } + }; + + return ( +
+ {/* Reward */} +
+ + Reward + +
+

+ {bounty.rewardAmount != null + ? `$${bounty.rewardAmount.toLocaleString()}` + : "TBD"} +

+

+ {bounty.rewardCurrency} +

+
+
+ + + + {/* Meta */} +
+
+ Status + +
+
+ Model + + + {claimCfg.label} + +
+ {bounty.difficulty && ( +
+ Difficulty + +
+ )} + {bounty.submissionsEndDate && ( +
+ Deadline + + + {new Date(bounty.submissionsEndDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + +
+ )} +
+ + + + {/* CTA */} + + + + + {!canAct && ( +

+ + {bounty.status === "claimed" + ? "A contributor has already claimed this bounty." + : "This bounty is no longer accepting submissions."} +

+ )} + + {/* GitHub */} + + + View on GitHub + + + {/* Copy link */} + +
+ ); +} + +export function ClaimModelInfo({ + claimingModel, +}: { + claimingModel: Bounty["claimingModel"]; +}) { + return ( +
+

+ Claim Model +

+

+ {CLAIMING_MODEL_CONFIG[claimingModel].description} +

+
+ ); +} + +export function MobileCTA({ bounty }: { bounty: Bounty }) { + const [dialogOpen, setDialogOpen] = useState(false); + const canAct = bounty.status === "open"; + + const label = () => { + if (!canAct) + return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; + switch (bounty.claimingModel) { + case "single-claim": + return "Claim Bounty"; + case "application": + return "Apply Now"; + case "competition": + return "Submit Entry"; + case "multi-winner": + return "Submit Work"; + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/components/bounty-detail/submission-dialog.tsx b/components/bounty-detail/submission-dialog.tsx new file mode 100644 index 0000000..aac7698 --- /dev/null +++ b/components/bounty-detail/submission-dialog.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useCallback, useEffect, 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"; + +interface SubmissionDialogProps { + bountyId: string; + bountyTitle: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const DRAFT_DEFAULTS: SubmissionFormValue = { + githubUrl: "", + demoUrl: "", + explanation: "", + attachments: [], + walletAddress: "", +}; + +export function SubmissionDialog({ + bountyId, + bountyTitle, + open, + onOpenChange, +}: SubmissionDialogProps) { + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const storageKey = `submission-draft-${bountyId}`; + const [draft, setDraft] = useLocalStorage( + storageKey, + null, + ); + + const form = useForm({ + resolver: zodResolver(submissionFormSchema), + defaultValues: draft ?? DRAFT_DEFAULTS, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "attachments" as never, + }); + + useEffect(() => { + if (open && draft) { + form.reset(draft); + } + 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) => { + setSubmitting(true); + try { + const payload = { + ...data, + githubUrl: data.githubUrl || undefined, + demoUrl: data.demoUrl || undefined, + attachments: data.attachments?.filter(Boolean), + contributorId: "current-user", + }; + + await bountiesApi.submit(bountyId, payload); + + clearDraft(); + form.reset(DRAFT_DEFAULTS); + setSubmitted(true); + toast.success("Submission sent successfully!"); + + setTimeout(() => { + 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 * + + + + + + The address where you'd like to receive the reward + + + + )} + /> + + ( + + + Explanation * + + +