diff --git a/app/(landing)/organizations/[id]/bounties/page.tsx b/app/(landing)/organizations/[id]/bounties/page.tsx index d2e86d98..08dd3c5d 100644 --- a/app/(landing)/organizations/[id]/bounties/page.tsx +++ b/app/(landing)/organizations/[id]/bounties/page.tsx @@ -1,15 +1,25 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { FileText, Plus, Trophy } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; +import { Calendar, FileText, Plus, Search, Target, Trash2 } from 'lucide-react'; import { BoundlessButton } from '@/components/buttons'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; +import DeleteBountyDraftDialog from '@/components/organization/bounties/DeleteBountyDraftDialog'; import { + useDeleteDraft, useDraftList, useOrganizationBounties, type BountyDraft, @@ -18,150 +28,378 @@ import { const DRAFT_STATUSES = new Set(['draft', 'draft_awaiting_funding']); -const STATUS_LABEL: Record = { - draft: 'Draft', - draft_awaiting_funding: 'Publishing', - open: 'Open', - in_progress: 'In progress', - submitted: 'Submitted', - under_review: 'Under review', - completed: 'Completed', - cancelled: 'Cancelled', - disputed: 'Disputed', +const STATUS_DISPLAY: Record = { + open: { + label: 'Open', + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + }, + in_progress: { + label: 'In progress', + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + }, + submitted: { + label: 'Submitted', + className: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + }, + under_review: { + label: 'Under review', + className: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + }, + completed: { + label: 'Completed', + className: 'border-primary/30 bg-primary/10 text-primary', + }, + cancelled: { + label: 'Cancelled', + className: 'border-zinc-700 bg-zinc-800/60 text-zinc-300', + }, + disputed: { + label: 'Disputed', + className: 'border-red-500/30 bg-red-500/10 text-red-400', + }, }; -function StatusBadge({ status }: { status: string }) { - const tone = - status === 'open' || status === 'in_progress' - ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' - : status === 'draft_awaiting_funding' - ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' - : status === 'completed' - ? 'border-primary/30 bg-primary/10 text-primary' - : 'border-zinc-700 bg-zinc-800/60 text-zinc-300'; +function statusDisplay(status: string) { return ( - - {STATUS_LABEL[status] ?? status} - + STATUS_DISPLAY[status] ?? { + label: status, + className: 'border-zinc-700 bg-zinc-800/60 text-zinc-300', + } ); } +const draftPrizePool = (draft: BountyDraft): number => + (draft.data?.reward?.prizeTiers ?? []).reduce( + (sum, tier) => sum + (Number(tier.amount) || 0), + 0 + ); + export default function OrganizationBountiesPage() { const params = useParams<{ id: string }>(); + const router = useRouter(); const organizationId = params?.id ?? ''; + const [tab, setTab] = useState<'published' | 'drafts'>('published'); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest'); + const draftsQuery = useDraftList(organizationId); const publishedQuery = useOrganizationBounties(organizationId); + const deleteDraft = useDeleteDraft(organizationId); + const [draftToDelete, setDraftToDelete] = useState<{ + id: string; + title: string; + } | null>(null); - const drafts: BountyDraft[] = draftsQuery.data ?? []; - // The root list returns every bounty; show only the published ones here - // (drafts have their own section above). - const published: OrganizationBountyListItem[] = useMemo( - () => - (publishedQuery.data ?? []).filter(b => !DRAFT_STATUSES.has(b.status)), - [publishedQuery.data] - ); + const allDrafts: BountyDraft[] = draftsQuery.data ?? []; + + const published: OrganizationBountyListItem[] = useMemo(() => { + let items = (publishedQuery.data ?? []).filter( + b => !DRAFT_STATUSES.has(b.status) + ); + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + items = items.filter(b => b.title?.toLowerCase().includes(q)); + } + return [...items].sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return sortBy === 'newest' ? tb - ta : ta - tb; + }); + }, [publishedQuery.data, searchQuery, sortBy]); + + const drafts: BountyDraft[] = useMemo(() => { + let items = allDrafts; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + items = items.filter(d => + (d.data?.scope?.title ?? '').toLowerCase().includes(q) + ); + } + return [...items].sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return sortBy === 'newest' ? tb - ta : ta - tb; + }); + }, [allDrafts, searchQuery, sortBy]); const isLoading = draftsQuery.isLoading || publishedQuery.isLoading; + const publishedCount = (publishedQuery.data ?? []).filter( + b => !DRAFT_STATUSES.has(b.status) + ).length; + const stats = { + total: publishedCount + allDrafts.length, + published: publishedCount, + drafts: allDrafts.length, + }; return ( }> -
-
-
-

Bounties

-

- Host and manage bounties for your organization. -

-
- - - - Host a bounty - - -
- - {isLoading ? ( -
- Loading bounties… -
- ) : ( -
- {/* Drafts */} -
-

- - Drafts -

- {drafts.length === 0 ? ( -

- No drafts yet. Start one with “Host a bounty”. -

- ) : ( -
- {drafts.map(draft => ( - -
-

- {draft.data?.scope?.title || 'Untitled bounty'} -

-

- {draft.modeLabel ?? 'Mode not set'} ·{' '} - {draft.completedSteps?.length ?? 0}/4 sections -

-
-
- - Resume -
- - ))} +
+ {/* Header */} +
+
+
+
+

+ Bounties +

+
+ {stats.total} total + + {stats.published} published + + {stats.drafts} drafts
- )} -
+
+ + + + Host a bounty + + +
- {/* Published */} -
-

- + {/* Tabs */} +
+

- {published.length === 0 ? ( -

- No published bounties yet. -

- ) : ( -
- {published.map(bounty => ( + + +
+ + {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} + className='focus:border-primary focus:ring-primary/20 h-10 rounded-xl border-zinc-800/50 bg-zinc-900/30 pl-10 text-sm text-white transition-all placeholder:text-zinc-500 hover:border-zinc-700 hover:bg-zinc-900/50' + /> +
+ +
+ + + + {/* Content */} +
+ {isLoading ? ( +
+
+ Loading bounties… +
+ ) : tab === 'published' ? ( + published.length === 0 ? ( + + ) : ( +
+ {published.map(bounty => { + const { label, className } = statusDisplay(bounty.status); + return (
-
-

- {bounty.title || 'Untitled bounty'} -

- {bounty.rewardAmount != null && ( -

- {bounty.rewardAmount.toLocaleString()}{' '} - {bounty.rewardCurrency ?? ''} -

+
+ + {label} + + {bounty.createdAt && ( + + + {new Date(bounty.createdAt).toLocaleDateString()} + )}
- +

+ {bounty.title || 'Untitled bounty'} +

+
+
+
+ +
+
+

+ {(bounty.rewardAmount ?? 0).toLocaleString()}{' '} + {bounty.rewardCurrency ?? 'USDC'} +

+

reward

+
+
+ + + {bounty._count?.submissions ?? 0} + +
- ))} -
- )} -
- - )} + ); + })} + + ) + ) : drafts.length === 0 ? ( + + ) : ( +
+ {drafts.map(draft => { + const title = draft.data?.scope?.title || 'Untitled bounty'; + const completion = Math.round( + ((draft.completedSteps?.length ?? 0) / 4) * 100 + ); + const isPublishing = draft.status === 'draft_awaiting_funding'; + const prizePool = draftPrizePool(draft); + return ( +
+ router.push( + `/organizations/${organizationId}/bounties/drafts/${draft.id}` + ) + } + tabIndex={0} + role='button' + aria-label={`Edit draft ${title}`} + > +
+ + {isPublishing ? 'Publishing' : 'Draft'} + + + {isPublishing ? 'Finalizing…' : `${completion}%`} + +
+

+ {title} +

+

+ {draft.modeLabel ?? 'Mode not set'} +

+
+
+
+
+
+ +
+
+

+ {prizePool > 0 + ? `${prizePool.toLocaleString()} ${draft.data?.reward?.rewardCurrency ?? 'USDC'}` + : 'No reward set'} +

+

reward

+
+
+ {!isPublishing && ( +
+ +
+ )} +
+ ); + })} +
+ )} +
+ + { + if (!open) setDraftToDelete(null); + }} + draftTitle={draftToDelete?.title ?? ''} + isDeleting={deleteDraft.isPending} + onConfirm={() => { + if (draftToDelete) deleteDraft.mutate(draftToDelete.id); + }} + />
); } + +function EmptyState({ + title, + organizationId, +}: { + title: string; + organizationId: string; +}) { + return ( +
+
+ +
+

{title}

+

+ Host a bounty to get contributors building. +

+ + + + Host a bounty + + +
+ ); +} diff --git a/components/organization/bounties/DeleteBountyDraftDialog.tsx b/components/organization/bounties/DeleteBountyDraftDialog.tsx new file mode 100644 index 00000000..3abdea8c --- /dev/null +++ b/components/organization/bounties/DeleteBountyDraftDialog.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { AlertTriangle } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface DeleteBountyDraftDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + draftTitle: string; + onConfirm: () => void; + isDeleting?: boolean; +} + +export default function DeleteBountyDraftDialog({ + open, + onOpenChange, + draftTitle, + onConfirm, + isDeleting = false, +}: DeleteBountyDraftDialogProps) { + return ( + + + +
+ +
+ + Delete this draft? + + + {draftTitle ? ( + <> + + "{draftTitle}" + {' '} + will be permanently deleted. This cannot be undone. + + ) : ( + 'This draft will be permanently deleted. This cannot be undone.' + )} + +
+ + + + Cancel + + + {isDeleting ? 'Deleting...' : 'Delete draft'} + + +
+
+ ); +} diff --git a/components/organization/bounties/new/NewBountyTab.tsx b/components/organization/bounties/new/NewBountyTab.tsx index d0429a63..362b860b 100644 --- a/components/organization/bounties/new/NewBountyTab.tsx +++ b/components/organization/bounties/new/NewBountyTab.tsx @@ -8,10 +8,12 @@ import { Tabs, TabsContent } from '@/components/ui/tabs'; import { useBountySteps } from '@/hooks/use-bounty-steps'; import { useBountyDraft } from '@/hooks/use-bounty-draft'; import { useBountyPublish } from '@/hooks/use-bounty-publish'; +import { buildMockBountyData } from './mock-data'; import ScopeTab from './tabs/ScopeTab'; import ModeTab from './tabs/ModeTab'; import SubmissionModelTab from './tabs/SubmissionModelTab'; import RewardTab from './tabs/RewardTab'; +import ResourcesTab from './tabs/ResourcesTab'; import ReviewTab from './tabs/ReviewTab'; import type { ModeSelection } from './tabs/schemas/modeSchema'; import { @@ -75,6 +77,7 @@ export default function NewBountyTab({ isSavingDraft, saveDraft, saveStep, + saveAllSections, } = useBountyDraft({ organizationId: derivedOrgId, initialDraftId: resolvedInitialDraftId, @@ -125,6 +128,7 @@ export default function NewBountyTab({ mode: false, submission: false, reward: false, + resources: false, review: false, }); @@ -158,13 +162,40 @@ export default function NewBountyTab({ // Publish via the shared escrow runner. Defaults to MANAGED (the connected // custodial wallet funds + the backend signs); the funding-source picker // (external wallet / treasury) is a follow-up. - const { publish, isPublishing } = useBountyPublish({ + const { publish, isPublishing, publishResponse } = useBountyPublish({ organizationId: derivedOrgId || '', stepData, draftId, fundingMode: 'MANAGED', }); + // Once the publish finalizes on-chain, leave the wizard for the organizer's + // bounty list rather than lingering on the (now read-only) review. + const hasRedirectedRef = useRef(false); + useEffect(() => { + if (publishResponse && derivedOrgId && !hasRedirectedRef.current) { + hasRedirectedRef.current = true; + router.replace(`/organizations/${derivedOrgId}/bounties`); + } + }, [publishResponse, derivedOrgId, router]); + + // Dev-only convenience: fill every section with valid mock data, persist it + // in one PATCH, and jump to Review. Never rendered in production builds. + const isDev = process.env.NODE_ENV === 'development'; + const [isFillingMock, setIsFillingMock] = useState(false); + const handleFillMock = useCallback(async () => { + setIsFillingMock(true); + try { + await saveAllSections(buildMockBountyData()); + navigateToStep('review'); + toast.success('Filled the wizard with mock data'); + } catch { + toast.error('Failed to fill mock data'); + } finally { + setIsFillingMock(false); + } + }, [saveAllSections, navigateToStep]); + if (isLoadingDraft) { return (
@@ -189,6 +220,18 @@ export default function NewBountyTab({ className='bg-background-main-bg mx-auto max-w-6xl flex-1 overflow-hidden px-6 py-8 text-white' id={organizationId} > + {isDev && ( +
+ +
+ )}
@@ -204,6 +247,7 @@ export default function NewBountyTab({ navigateToStep('submission')} + onBack={() => navigateToStep('scope')} initialData={stepData.mode} isLoading={loadingStates.mode} /> @@ -212,8 +256,10 @@ export default function NewBountyTab({ navigateToStep('reward')} + onBack={() => navigateToStep('mode')} initialData={stepData.submission} isLoading={loadingStates.submission} /> @@ -229,17 +275,29 @@ export default function NewBountyTab({ } : undefined } - onSave={createSaveHandler('reward', 'review')} - onContinue={() => navigateToStep('review')} + onSave={createSaveHandler('reward', 'resources')} + onContinue={() => navigateToStep('resources')} + onBack={() => navigateToStep('submission')} initialData={stepData.reward} isLoading={loadingStates.reward} /> + + navigateToStep('review')} + onBack={() => navigateToStep('reward')} + initialData={stepData.resources} + isLoading={loadingStates.resources} + /> + + navigateToStep('resources')} onSaveDraft={saveDraft} isSavingDraft={isSavingDraft} onPublish={async () => { diff --git a/components/organization/bounties/new/constants.ts b/components/organization/bounties/new/constants.ts index 2ef8220e..fff92bbd 100644 --- a/components/organization/bounties/new/constants.ts +++ b/components/organization/bounties/new/constants.ts @@ -2,10 +2,17 @@ import type { ModeFormData } from './tabs/schemas/modeSchema'; import type { SubmissionModelFormData } from './tabs/schemas/submissionModelSchema'; import type { ScopeFormData } from './tabs/schemas/scopeSchema'; import type { RewardFormData } from './tabs/schemas/rewardSchema'; +import type { ResourcesFormData } from './tabs/schemas/resourcesSchema'; export type StepStatus = 'pending' | 'active' | 'completed'; -export type StepKey = 'scope' | 'mode' | 'submission' | 'reward' | 'review'; +export type StepKey = + | 'scope' + | 'mode' + | 'submission' + | 'reward' + | 'resources' + | 'review'; export interface StepData { status: StepStatus; @@ -13,12 +20,14 @@ export interface StepData { data?: Record; } -/** The five wizard steps, in order. `review` carries no editable section. */ +/** The wizard steps, in order. `resources` is optional; `review` carries no + * editable section. */ export const STEP_ORDER: StepKey[] = [ 'scope', 'mode', 'submission', 'reward', + 'resources', 'review', ]; @@ -31,6 +40,7 @@ export interface BountyFormData { mode?: ModeFormData; submission?: SubmissionModelFormData; reward?: RewardFormData; + resources?: ResourcesFormData; } /** @@ -60,6 +70,9 @@ export const isBountyStepDataValid = ( reward?.rewardCurrency && (reward?.prizeTiers?.length ?? 0) > 0 ); } + case 'resources': + // Optional step: never blocks the resume from advancing to review. + return true; case 'review': return false; default: diff --git a/components/organization/bounties/new/mock-data.ts b/components/organization/bounties/new/mock-data.ts new file mode 100644 index 00000000..14100f06 --- /dev/null +++ b/components/organization/bounties/new/mock-data.ts @@ -0,0 +1,66 @@ +import type { BountyFormData } from './constants'; + +/** A `datetime-local` string (YYYY-MM-DDTHH:mm) `days` from now. */ +function futureLocal(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + d.setSeconds(0, 0); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( + d.getDate() + )}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * A complete, publish-valid bounty configuration for the dev-only "Fill with + * mock" button. Uses the simplest valid mode (open, single claim) so every + * section passes its schema and the publish gate. Development category, so the + * GitHub issue URL is present; reputation matches the development floor. + */ +export function buildMockBountyData(): BountyFormData { + return { + scope: { + title: 'Build a Soroban faucet bot', + description: + '## Overview\n\nMock bounty generated for local testing.\n\n' + + '### What needs to be built\n\n' + + '- A Telegram bot that drips testnet XLM\n' + + '- Rate limiting per user\n' + + '- A simple `/faucet` command\n\n' + + '**Done looks like:** a deployed bot with a short README.', + category: 'DEVELOPMENT', + country: 'United States', + githubIssueUrl: 'https://github.com/boundlessfi/boundless/issues/1', + projectId: null, + bountyWindowId: null, + }, + mode: { + entryType: 'OPEN', + claimType: 'SINGLE_CLAIM', + winnerCount: 1, + }, + submission: { + submissionDeadline: futureLocal(30), + submissionVisibility: 'ORGANIZER_ONLY', + reputationMinimum: 50, + applicationWindowCloseAt: null, + maxApplicants: null, + shortlistSize: null, + applicationCreditCost: null, + }, + reward: { + rewardCurrency: 'USDC', + prizeTiers: [{ position: 1, amount: '100', passMark: null }], + }, + resources: { + resources: [ + { + id: 'resource-mock-1', + link: 'https://developers.stellar.org/docs', + description: 'Stellar developer documentation', + file: undefined, + }, + ], + }, + }; +} diff --git a/components/organization/bounties/new/tabs/ModeTab.tsx b/components/organization/bounties/new/tabs/ModeTab.tsx index f0f96ebe..f25e7ef4 100644 --- a/components/organization/bounties/new/tabs/ModeTab.tsx +++ b/components/organization/bounties/new/tabs/ModeTab.tsx @@ -11,6 +11,7 @@ import { Loader2, Minus, Plus, + ArrowLeft, } from 'lucide-react'; import { BoundlessButton } from '@/components/buttons'; @@ -28,6 +29,7 @@ import { interface ModeTabProps { onContinue?: () => void; + onBack?: () => void; onSave?: (data: ModeFormData) => Promise; initialData?: Partial; isLoading?: boolean; @@ -87,6 +89,7 @@ const defaultValues: ModeFormData = { export default function ModeTab({ onContinue, + onBack, onSave, initialData, isLoading = false, @@ -314,7 +317,21 @@ export default function ModeTab({

-
+
+ {onBack ? ( + + + Back + + ) : ( + + )} void; + onBack?: () => void; + onSave?: (data: ResourcesFormData) => Promise; + initialData?: ResourcesFormData; + isLoading?: boolean; +} + +interface ResourceRowProps { + index: number; + onRemove: (index: number) => void; + control: Control; +} + +const ResourceRow = ({ index, onRemove, control }: ResourceRowProps) => { + return ( +
+
+
+ +
+
+ {/* Link */} + ( + + + Link (optional) + + +
+ + +
+
+ +
+ )} + /> + + {/* Description */} + ( + + + Description (optional) + + +