From 9868243fddf5257c4024d4d20aa2d0d22adf0e73 Mon Sep 17 00:00:00 2001 From: User Date: Wed, 24 Jun 2026 02:34:33 -0700 Subject: [PATCH] feat: implement sponsor bounty creation flow --- app/bounty/create/page.tsx | 55 +- components/bounty/bounty-create-form.tsx | 793 +++++++++++++++++++++++ components/global-navbar.tsx | 19 +- hooks/use-create-bounty.ts | 40 ++ 4 files changed, 875 insertions(+), 32 deletions(-) create mode 100644 components/bounty/bounty-create-form.tsx create mode 100644 hooks/use-create-bounty.ts diff --git a/app/bounty/create/page.tsx b/app/bounty/create/page.tsx index 2255cfc2..7837548c 100644 --- a/app/bounty/create/page.tsx +++ b/app/bounty/create/page.tsx @@ -4,50 +4,45 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/auth-client"; import { useUserRole } from "@/hooks/use-user-role"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { AlertCircle } from "lucide-react"; +import { BountyCreateForm } from "@/components/bounty/bounty-create-form"; + +interface ExtendedUser { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + organizations?: string[]; +} export default function CreateBountyPage() { const router = useRouter(); - const { isPending } = authClient.useSession(); + const { data: session, isPending } = authClient.useSession(); const userRole = useUserRole(); + const user = session?.user as ExtendedUser | undefined; + const isSponsorOrOrgMember = user && ( + user.role === "sponsor" || + (user.organizations && user.organizations.length > 0) + ); + useEffect(() => { - // Redirect to /bounty if the user is not a sponsor - if (!isPending && userRole !== "sponsor") { + // Redirect to /bounty if the user is not authorized as a sponsor or organization member + if (!isPending && !isSponsorOrOrgMember) { router.push("/bounty"); } - }, [userRole, isPending, router]); + }, [isSponsorOrOrgMember, isPending, router]); // Show nothing while checking auth or redirecting - if (isPending || userRole !== "sponsor") { + if (isPending || !isSponsorOrOrgMember) { return null; } return ( -
-

Create a Bounty

- - -
- - Coming Soon -
- - The bounty creation form is under development - -
- - We're building a powerful form to help you create bounties. Check - back soon! - -
+
+

+ Bounty Creation Portal +

+
); } diff --git a/components/bounty/bounty-create-form.tsx b/components/bounty/bounty-create-form.tsx new file mode 100644 index 00000000..9d06636b --- /dev/null +++ b/components/bounty/bounty-create-form.tsx @@ -0,0 +1,793 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { format } from "date-fns"; +import { + CalendarIcon, + ArrowRight, + ArrowLeft, + Check, + Sparkles, + Github, + HelpCircle, + CheckCircle2, + Loader2 +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { authClient } from "@/lib/auth-client"; +import { useCreateBounty } from "@/hooks/use-create-bounty"; +import { useLightningRounds, getRoundPhase } from "@/hooks/use-lightning-rounds"; +import { mockProjects } from "@/lib/mock/projects"; +import { BountyType } from "@/lib/graphql/generated"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Calendar } from "@/components/ui/calendar"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + BudgetInput, + DeadlineInput, + MarkdownTextarea, + MilestoneBuilder +} from "@/components/bounty/forms"; + +interface ExtendedUser { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + organizations?: string[]; +} + +// Zod Schema for the Form +const bountyCreateSchema = z.object({ + title: z + .string() + .min(3, "Title must be at least 3 characters") + .max(100, "Title is too long"), + type: z.nativeEnum(BountyType, { + required_error: "Please select a bounty type" + }), + organizationId: z.string().min(1, "Organization is required"), + projectId: z.string().optional(), + githubIssueUrl: z.string().optional(), + description: z + .string() + .min(10, "Description must be at least 10 characters") + .max(10000, "Description is too long"), + reward: z.object({ + amount: z + .number({ required_error: "Amount is required" }) + .positive("Amount must be greater than 0") + .max(1000000, "Amount must be less than 1,000,000"), + asset: z.string().min(1, "Please select an asset"), + }), + deadline: z.date().optional(), + bountyWindowId: z.string().optional(), + startDate: z.date().optional(), + endDate: z.date().optional(), + milestones: z + .array( + z.object({ + title: z.string().min(1, "Milestone title is required").max(100), + description: z.string().optional(), + percentage: z + .number({ required_error: "% is required" }) + .min(1, "Min percentage is 1%") + .max(100, "Max percentage is 100%"), + }) + ) + .optional(), +}).superRefine((data, ctx) => { + // FIXED_PRICE checks + if (data.type === BountyType.FixedPrice) { + if (!data.githubIssueUrl || !data.githubIssueUrl.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["githubIssueUrl"], + message: "GitHub issue URL is required for Fixed Price bounties", + }); + } else if (!data.githubIssueUrl.includes("github.com")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["githubIssueUrl"], + message: "Must be a valid GitHub URL", + }); + } + + if (!data.deadline) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["deadline"], + message: "Deadline is required for Fixed Price bounties", + }); + } + } + + // General deadline validation + if (data.deadline && data.deadline <= new Date()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["deadline"], + message: "Deadline must be in the future", + }); + } + + // COMPETITION checks + if (data.type === BountyType.Competition) { + if (!data.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["startDate"], + message: "Start date is required for Competitions", + }); + } + if (!data.endDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date is required for Competitions", + }); + } else if (data.startDate && data.endDate <= data.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endDate"], + message: "End date must be after start date", + }); + } + } + + // MILESTONE_BASED checks + if (data.type === BountyType.MilestoneBased) { + if (!data.deadline) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["deadline"], + message: "Deadline is required for Milestone-based bounties", + }); + } + + if (!data.milestones || data.milestones.length < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["milestones"], + message: "At least 2 milestones are required", + }); + } else { + const sum = data.milestones.reduce((acc, m) => acc + (m.percentage || 0), 0); + if (sum !== 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["milestones"], + message: `Milestone percentages must sum to exactly 100% (currently ${sum}%)`, + }); + } + } + } +}); + +type BountyFormValues = z.infer; + +export function BountyCreateForm() { + const [step, setStep] = useState(1); + const { data: session } = authClient.useSession(); + const { createBounty, isPending: isSubmitting } = useCreateBounty(); + const { rounds } = useLightningRounds(); + + // Parse user organizations & fallbacks + const user = session?.user as ExtendedUser | undefined; + const userOrgs = user?.organizations || []; + const organizations = userOrgs.length > 0 + ? userOrgs.map(org => ({ id: org, name: org })) + : [ + { id: "org-wallet", name: "Acme Wallet" }, + { id: "org-defi", name: "DeFi Protocol" }, + { id: "org-security", name: "Stellar Security" } + ]; + + // Active/Upcoming lightning rounds + const activeOrUpcomingRounds = (rounds || []).filter(r => { + const phase = getRoundPhase(r); + return phase === "active" || phase === "upcoming"; + }); + + const form = useForm({ + resolver: zodResolver(bountyCreateSchema), + mode: "onChange", + defaultValues: { + title: "", + type: BountyType.FixedPrice, + organizationId: organizations[0]?.id || "", + projectId: "", + githubIssueUrl: "", + description: "", + reward: { + amount: undefined as any, + asset: "USDC", + }, + bountyWindowId: "", + milestones: [ + { title: "Milestone 1: Design & Spec", description: "", percentage: 50 }, + { title: "Milestone 2: Final Implementation", description: "", percentage: 50 }, + ], + }, + }); + + const watchType = form.watch("type"); + + const handleNext = async () => { + let fieldsToValidate: Array = []; + if (step === 1) { + fieldsToValidate = ["title", "type", "organizationId", "projectId", "githubIssueUrl", "description"]; + } else if (step === 2) { + fieldsToValidate = ["reward", "deadline", "bountyWindowId", "startDate", "endDate", "milestones"]; + } + + const isValid = await form.trigger(fieldsToValidate); + if (isValid) { + setStep(prev => prev + 1); + } + }; + + const handleBack = () => { + setStep(prev => prev - 1); + }; + + const parseGithubIssueNumber = (url: string): number | undefined => { + try { + const match = url.match(/\/issues\/(\d+)/); + return match ? parseInt(match[1]) : undefined; + } catch { + return undefined; + } + }; + + const onSubmit = async (values: BountyFormValues) => { + const input = { + title: values.title, + type: values.type, + description: values.description, + organizationId: values.organizationId, + projectId: values.projectId || undefined, + githubIssueUrl: values.githubIssueUrl || "", + githubIssueNumber: values.githubIssueUrl ? parseGithubIssueNumber(values.githubIssueUrl) : undefined, + rewardAmount: values.reward.amount, + rewardCurrency: values.reward.asset, + bountyWindowId: values.bountyWindowId || undefined, + }; + + createBounty(input); + }; + + return ( + + + + + Create New Bounty + + + Publish a bounty to the platform for contributors to claim and build. + + + + {/* Step Indicator */} +
+
+
= 1 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground" + )}> + {step > 1 ? : "1"} +
+ +
+ +
= 2 ? "bg-primary" : "bg-border/30")} /> + +
+
= 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground" + )}> + {step > 2 ? : "2"} +
+ +
+ +
= 3 ? "bg-primary" : "bg-border/30")} /> + +
+
+ 3 +
+ +
+
+ +
+ + {/* STEP 1: Basic Info */} + {step === 1 && ( +
+ ( + + Bounty Title + + + + Choose a clear, descriptive title for the bounty. + + + )} + /> + +
+ ( + + Bounty Type + + Choose how work is structured and paid. + + + )} + /> + + ( + + Organization + + Select the organization funding this bounty. + + + )} + /> +
+ + ( + + Associated Project (Optional) + + Link this bounty to a specific project workspace. + + + )} + /> + + {watchType === BountyType.FixedPrice && ( + ( + + + + GitHub Issue URL + + + + + Provide the GitHub issue URL linked to this Fixed Price bounty. + + + )} + /> + )} + + +
+ )} + + {/* STEP 2: Rewards & Deadlines */} + {step === 2 && ( +
+ + +
+ ( + + Lightning Round Window (Optional) + + Associate this bounty with an active/upcoming Lightning Round window. + + + )} + /> + + {watchType !== BountyType.Competition && ( + + )} +
+ + {watchType === BountyType.Competition && ( +
+ ( + + Competition Start Date + + + + + + + + date < new Date(new Date().setHours(0,0,0,0))} + initialFocus + /> + + + + + )} + /> + + ( + + Competition End Date + + + + + + + + { + const start = form.getValues("startDate"); + return date < (start || new Date()); + }} + initialFocus + /> + + + + + )} + /> +
+ )} + + {watchType === BountyType.MilestoneBased && ( +
+
+

Milestones Definition

+

+ Define progress milestones and allocate the payout percentages. Must total exactly 100%. +

+
+ +
+ )} +
+ )} + + {/* STEP 3: Review */} + {step === 3 && ( +
+
+
+ +

Confirm Bounty Details

+
+ +
+
+ Title + {form.getValues("title")} +
+ +
+ Bounty Type + + {form.getValues("type")} + +
+ +
+ Organization + + {organizations.find(o => o.id === form.getValues("organizationId"))?.name || form.getValues("organizationId")} + +
+ +
+ Associated Project + + {mockProjects.find(p => p.id === form.getValues("projectId"))?.name || "None"} + +
+ + {form.getValues("githubIssueUrl") && ( + + )} + +
+ Reward Budget + + {form.getValues("reward.amount")} {form.getValues("reward.asset")} + +
+ + {form.getValues("bountyWindowId") && ( +
+ Lightning Round Window + + {rounds?.find(r => r.id === form.getValues("bountyWindowId"))?.name || "Window ID " + form.getValues("bountyWindowId")} + +
+ )} + + {watchType === BountyType.Competition ? ( + <> +
+ Start Date + + {form.getValues("startDate") ? format(form.getValues("startDate")!, "PPP") : "-"} + +
+
+ End Date + + {form.getValues("endDate") ? format(form.getValues("endDate")!, "PPP") : "-"} + +
+ + ) : ( +
+ Deadline + + {form.getValues("deadline") ? format(form.getValues("deadline")!, "PPP") : "-"} + +
+ )} +
+ + {watchType === BountyType.MilestoneBased && form.getValues("milestones") && ( +
+ Milestones Breakdown +
+ {form.getValues("milestones")!.map((m, idx) => ( +
+
+ Milestone {idx + 1}: {m.title} + {m.description &&

{m.description}

} +
+ + {m.percentage}% + +
+ ))} +
+
+ )} + +
+ Description Draft +
+ {form.getValues("description")} +
+
+
+ +
+ + + Publishing a bounty will create a transaction and index the details to the explorer network. Please double check that all details are correct. + +
+
+ )} + + {/* Form Actions */} +
+ {step > 1 ? ( + + ) : ( +
+ )} + + {step < 3 ? ( + + ) : ( + + )} +
+ + + + + ); +} diff --git a/components/global-navbar.tsx b/components/global-navbar.tsx index 5d819ee9..2b80a49c 100644 --- a/components/global-navbar.tsx +++ b/components/global-navbar.tsx @@ -22,12 +22,27 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { useUserRole } from "@/hooks/use-user-role"; +import { authClient } from "@/lib/auth-client"; import { Wallet, LogIn, Fingerprint } from "lucide-react"; +interface ExtendedUser { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + organizations?: string[]; +} + export function GlobalNavbar() { const pathname = usePathname(); const userRole = useUserRole(); + const { data: session } = authClient.useSession(); + const user = session?.user as ExtendedUser | undefined; + const isSponsorOrOrgMember = user && ( + user.role === "sponsor" || + (user.organizations && user.organizations.length > 0) + ); const { walletInfo, isConnected, isRegistered, connect, isLoading } = useSmartWallet(); @@ -114,7 +129,7 @@ export function GlobalNavbar() { > Wallet - {userRole === "sponsor" && ( + {isSponsorOrOrgMember && ( - Create + Create Bounty )} { + // Invalidate bounty lists to refresh lists and caches + queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); + toast.success("Bounty created successfully!"); + + const bountyId = data?.createBounty?.id; + if (bountyId) { + router.push(`/bounty/${bountyId}`); + } else { + router.push("/bounty"); + } + }, + onError: (error: any) => { + toast.error(error.message || "Failed to create bounty"); + }, + }); + + return { + ...mutation, + createBounty: (input: CreateBountyInput) => { + return mutation.mutate({ input }); + }, + createBountyAsync: (input: CreateBountyInput) => { + return mutation.mutateAsync({ input }); + }, + }; +}