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
-
-
-
-
- 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"}
+
+
= 1 ? "text-foreground" : "text-muted-foreground")}>
+ Basic Info
+
+
+
+
= 2 ? "bg-primary" : "bg-border/30")} />
+
+
+
= 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
+ )}>
+ {step > 2 ? : "2"}
+
+
= 2 ? "text-foreground" : "text-muted-foreground")}>
+ Rewards & Deadlines
+
+
+
+
= 3 ? "bg-primary" : "bg-border/30")} />
+
+
+
+
+
+
+
+
+ );
+}
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 });
+ },
+ };
+}