From ebb34f46e1794d594de0e452faad295933ca549b Mon Sep 17 00:00:00 2001 From: Nana Abdul <138733058+nanaabdul1172@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:09:02 +0000 Subject: [PATCH 1/2] implemented multi-winner milestone flow --- app/profile/[userId]/page.tsx | 65 +++- .../bounty-detail-milestone-flow-card.tsx | 363 ++++++++++++++++++ .../bounty-detail-sidebar-cta.tsx | 6 + .../bounty-detail-submissions-card.tsx | 17 +- hooks/__tests__/use-milestone-flow.test.ts | 103 +++++ hooks/use-milestone-flow.ts | 282 ++++++++++++++ types/milestone-flow.ts | 53 +++ 7 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 components/bounty-detail/bounty-detail-milestone-flow-card.tsx create mode 100644 hooks/__tests__/use-milestone-flow.test.ts create mode 100644 hooks/use-milestone-flow.ts create mode 100644 types/milestone-flow.ts diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 66e1b30..9d67cfd 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -17,6 +17,45 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useMemo } from "react"; import { useCompletionHistory } from "@/hooks/use-reputation"; +import type { MilestoneFlowState } from "@/types/milestone-flow"; + +function getMilestoneClaimForUser( + bountyId: string, + viewerId: string, +): { status: string; nextMilestone?: string } | null { + if (typeof window === "undefined") return null; + + const key = `milestone_flow_${bountyId}`; + const serialized = window.localStorage.getItem(key); + if (!serialized) return null; + + try { + const flow = JSON.parse(serialized) as MilestoneFlowState; + const participant = flow.participants.find( + (item) => item.contributorId === viewerId, + ); + + if (!participant) return null; + + const milestone = flow.milestones[participant.currentMilestoneIndex]; + + if (participant.status === "COMPLETED") { + return { status: "completed" }; + } + + if (participant.status === "REJECTED") { + return { status: "disputed" }; + } + + if (participant.status === "SUBMITTED") { + return { status: "submitted", nextMilestone: milestone?.title }; + } + + return { status: "in-progress", nextMilestone: milestone?.title }; + } catch { + return null; + } +} export default function ProfilePage() { const params = useParams(); @@ -42,10 +81,11 @@ export default function ProfilePage() { const myClaims = useMemo(() => { const bounties = bountyResponse?.data ?? []; + const claimMap = new Map(); - return bounties + bounties .filter((bounty) => bounty.createdBy === userId) - .map((bounty) => { + .forEach((bounty) => { let status = "unknown"; if (bounty.status === "COMPLETED") { @@ -64,13 +104,30 @@ export default function ProfilePage() { status = "open"; } - return { + claimMap.set(bounty.id, { bountyId: bounty.id, title: bounty.title, status, rewardAmount: bounty.rewardAmount ?? undefined, - }; + }); + }); + + bounties + .filter((bounty) => bounty.type === "MILESTONE_BASED") + .forEach((bounty) => { + const milestoneClaim = getMilestoneClaimForUser(bounty.id, userId); + if (!milestoneClaim) return; + + claimMap.set(bounty.id, { + bountyId: bounty.id, + title: bounty.title, + status: milestoneClaim.status, + nextMilestone: milestoneClaim.nextMilestone, + rewardAmount: bounty.rewardAmount ?? undefined, + }); }); + + return Array.from(claimMap.values()); }, [bountyResponse?.data, userId]); const earningsSummary = useMemo(() => { diff --git a/components/bounty-detail/bounty-detail-milestone-flow-card.tsx b/components/bounty-detail/bounty-detail-milestone-flow-card.tsx new file mode 100644 index 0000000..4d8c71b --- /dev/null +++ b/components/bounty-detail/bounty-detail-milestone-flow-card.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { CheckCircle2, Clock3, GitBranch, Users, XCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { authClient } from "@/lib/auth-client"; +import { useMilestoneFlow } from "@/hooks/use-milestone-flow"; + +interface ExtendedUser { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + organizations?: string[]; +} + +interface MilestoneFlowCardProps { + bounty: { + id: string; + title: string; + status: string; + organizationId: string; + rewardAmount?: number | null; + rewardCurrency?: string | null; + }; +} + +function isSafeHttpUrl(url: string) { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function BountyDetailMilestoneFlowCard({ + bounty, +}: MilestoneFlowCardProps) { + const { data: session } = authClient.useSession(); + const currentUser = (session?.user as ExtendedUser | undefined) ?? null; + const currentUserId = currentUser?.id ?? ""; + const currentUserName = + currentUser?.name?.trim() || currentUser?.email?.trim() || "Anonymous"; + + const [prUrl, setPrUrl] = useState(""); + const [comments, setComments] = useState(""); + const [actionError, setActionError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isJoining, setIsJoining] = useState(false); + + const { + state, + pendingSubmissions, + stageOccupancy, + getParticipant, + joinFlow, + submitMilestone, + reviewSubmission, + } = useMilestoneFlow(bounty.id); + + const canAct = bounty.status === "OPEN"; + const isOrgMember = + (currentUser?.organizations ?? []).includes(bounty.organizationId) ?? false; + const participant = currentUserId ? getParticipant(currentUserId) : undefined; + + const activeMilestone = + participant && participant.currentMilestoneIndex < state.milestones.length + ? state.milestones[participant.currentMilestoneIndex] + : undefined; + + const approvedCount = useMemo(() => { + return state.participants.filter((item) => item.status === "COMPLETED") + .length; + }, [state.participants]); + + const handleJoin = async () => { + if (!currentUserId || !canAct) return; + setActionError(null); + setIsJoining(true); + try { + joinFlow(currentUserId, currentUserName); + } catch (error) { + setActionError( + error instanceof Error ? error.message : "Failed to join flow.", + ); + } finally { + setIsJoining(false); + } + }; + + const handleSubmitMilestone = async () => { + if (!currentUserId || !participant || !canAct) return; + if (!isSafeHttpUrl(prUrl)) { + setActionError("Enter a valid http(s) pull request URL."); + return; + } + + setActionError(null); + setIsSubmitting(true); + try { + submitMilestone( + currentUserId, + prUrl.trim(), + comments.trim() || undefined, + ); + setPrUrl(""); + setComments(""); + } catch (error) { + setActionError( + error instanceof Error ? error.message : "Failed to submit milestone.", + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleReview = ( + submissionId: string, + decision: "APPROVED" | "REJECTED", + ) => { + if (!currentUserId) return; + setActionError(null); + try { + reviewSubmission(submissionId, currentUserId, decision); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Review failed."); + } + }; + + return ( +
+
+
+

+ Multi-Winner Milestone Flow +

+
+ + {state.participants.length} participants +
+
+ +
+ {state.milestones.map((milestone, index) => { + const occupied = stageOccupancy[index] ?? 0; + const percent = Math.min( + 100, + Math.round((occupied / Math.max(milestone.maxWinners, 1)) * 100), + ); + + return ( +
+

+ {milestone.title} +

+

{milestone.description}

+

+ Slots: {occupied}/{milestone.maxWinners} | Payout:{" "} + {milestone.rewardPercentage}% +

+
+
+
+
+ ); + })} +
+ +
+ + + Final winners: {approvedCount} + + + + Pending reviews: {pendingSubmissions.length} + +
+
+ +
+

+ Contributor Actions +

+ + {!currentUserId && ( +

+ Sign in to join this milestone flow and submit deliverables. +

+ )} + + {currentUserId && !participant && ( +
+

+ Join Milestone 1 to start. Progression to later milestones is + based on approvals. +

+ +
+ )} + + {participant && ( +
+
+

Your status

+

+ {participant.status} +

+ {activeMilestone && ( +

+ Current stage: {activeMilestone.title} +

+ )} +
+ + {participant.status === "ACTIVE" && ( +
+
+ + setPrUrl(event.target.value)} + placeholder="https://github.com/org/repo/pull/123" + /> +
+ +
+ +