diff --git a/app/bounty/page.tsx b/app/bounty/page.tsx index cea92360..973f278a 100644 --- a/app/bounty/page.tsx +++ b/app/bounty/page.tsx @@ -35,10 +35,17 @@ import { type BountyQueryInput, } from "@/lib/graphql/generated"; -const BOUNTY_TYPES: { value: BountyType; label: string }[] = [ +const BOUNTY_TYPES: { + value: BountyType | "MULTI_WINNER_MILESTONE"; + label: string; +}[] = [ { value: BountyType.FixedPrice, label: "Fixed Price" }, { value: BountyType.MilestoneBased, label: "Milestone Based" }, { value: BountyType.Competition, label: "Competition" }, + { + value: "MULTI_WINNER_MILESTONE" as unknown as BountyType, + label: "Multi-Winner Milestone", + }, ]; const STATUSES: { value: BountyStatus | "all"; label: string }[] = [ @@ -100,8 +107,10 @@ export default function BountiesPage() { const currentPage = pagination?.page ?? page; const totalPages = pagination?.totalPages ?? 1; - const toggleType = (type: BountyType) => { - setSelectedType((prev) => (prev === type ? "all" : type)); + const toggleType = (type: BountyType | "MULTI_WINNER_MILESTONE") => { + setSelectedType((prev) => + prev === type ? "all" : (type as unknown as BountyType), + ); setPage(1); }; diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 3f16cae9..e859830a 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -11,7 +11,6 @@ import { import { LeaderboardTimeframe } from "@/lib/graphql/generated"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { authClient } from "@/lib/auth-client"; import { TIMEFRAMES, TIERS, @@ -46,9 +45,9 @@ export default function LeaderboardPage() { tags: initialTags || [], }); - // Get current user ID from auth session - const { data: session } = authClient.useSession(); - const currentUserId = session?.user?.id; + // Fake current user ID for demo purposes + // In a real app this would come from auth context + const currentUserId = "user-1"; // Debounce filters to prevent rapid API calls/URL updates const [debouncedFilters, setDebouncedFilters] = @@ -136,7 +135,7 @@ export default function LeaderboardPage() { onLoadMore={() => fetchNextPage()} currentUserId={currentUserId} onRowClick={(entry) => - router.push(`/profile/${entry.contributor.userId}`) + router.push(`/user/${entry.contributor.userId}`) } /> )} diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx index c032cb17..fed32e8c 100644 --- a/components/bounty-detail/bounty-detail-client.tsx +++ b/components/bounty-detail/bounty-detail-client.tsx @@ -15,7 +15,49 @@ import { EscrowDetailPanel } from "../bounty/escrow-detail-panel"; import { RefundStatusTracker } from "../bounty/refund-status"; import { FeeCalculator } from "../bounty/fee-calculator"; import { useEscrowPool } from "@/hooks/use-escrow"; +import { authClient } from "@/lib/auth-client"; import type { CancellationRecord } from "@/types/escrow"; +import { MilestoneFunnel } from "@/components/bounty/milestone-funnel"; +import { + MOCK_MODEL4_MILESTONES, + MOCK_MODEL4_CONTRIBUTORS, +} from "@/lib/mock-model4"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { MilestoneSubmissionCard } from "./milestone-submission-card"; +import { Model4MaintainerDashboard } from "./model4-maintainer-dashboard"; +import type { Milestone, ContributorProgress } from "@/types/bounty"; + +type BountyData = ReturnType["data"]; + +/** Returns milestones with mock fallback. Safe for public display since + * milestones are structural (titles/descriptions), not personal data. + */ +function getMilestones(bounty: BountyData): Milestone[] { + return bounty?.milestones ?? MOCK_MODEL4_MILESTONES; +} + +/** Returns contributorProgress WITHOUT mock fallback — only real API data. + * Used for the public MilestoneFunnel to prevent mock users (Alice, Bob…) + * from being displayed to unauthenticated visitors. + */ +function getRealContributors(bounty: BountyData): ContributorProgress[] { + return bounty?.contributorProgress ?? []; +} + +/** Returns full data including mock contributorProgress fallback. + * Used only in authenticated sections (contributor progress card, + * maintainer dashboard) where mocks are acceptable during prototyping. + */ +function getFullMilestoneData(bounty: BountyData): { + milestones: Milestone[]; + contributorProgress: ContributorProgress[]; +} { + return { + milestones: bounty?.milestones ?? MOCK_MODEL4_MILESTONES, + contributorProgress: + bounty?.contributorProgress ?? MOCK_MODEL4_CONTRIBUTORS, + }; +} export function BountyDetailClient({ bountyId }: { bountyId: string }) { const router = useRouter(); @@ -24,6 +66,8 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) { const [cancellationRecord, setCancellationRecord] = useState(null); + const { data: session } = authClient.useSession(); + const handleCancelled = useCallback((record: CancellationRecord) => { setCancellationRecord(record); }, []); @@ -87,6 +131,58 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
+ + {bounty.type === "MULTI_WINNER_MILESTONE" && ( + + + + Milestone Funnel + + Multi-Winner + + + + + {/* contributors is intentionally real-data-only: mock users + (Alice, Bob…) must not be shown to unauthenticated visitors */} + + + + )} + + {bounty.type === "MULTI_WINNER_MILESTONE" && + session?.user?.id && + (() => { + const { milestones, contributorProgress } = + getFullMilestoneData(bounty); + const myProgress = contributorProgress.find( + (c) => c.userId === session.user.id, + ); + if (!myProgress) return null; + return ( + + ); + })()} + + {bounty.type === "MULTI_WINNER_MILESTONE" && + session?.user?.id === bounty.createdBy && + (() => { + const { milestones, contributorProgress } = + getFullMilestoneData(bounty); + return ( + + ); + })()} + {!isCancelled && pool && } {bounty.type !== "FIXED_PRICE" && ( diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index b10ccc26..9c1a72ef 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -8,6 +8,7 @@ import { AlertCircle, XCircle, Loader2, + Users, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -29,14 +30,21 @@ import { FcfsClaimButton } from "@/components/bounty/fcfs-claim-button"; import { authClient } from "@/lib/auth-client"; import type { CancellationRecord } from "@/types/escrow"; import { useCancelBountyDialog } from "@/hooks/use-cancel-bounty-dialog"; +import type { Bounty } from "@/types/bounty"; + +/** Props accept the wider intersection returned by useBountyDetail so + * callers don't need a cast. Optional Bounty fields (maxSlots, etc.) + * are accessible without unsafe assertions. */ +type SidebarBounty = BountyFieldsFragment & Partial; interface SidebarCTAProps { - bounty: BountyFieldsFragment; + bounty: SidebarBounty; onCancelled?: (record: CancellationRecord) => void; } export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { const [copied, setCopied] = useState(false); + const [isApplying, setIsApplying] = useState(false); const { data: session } = authClient.useSession(); const { @@ -112,6 +120,21 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { Type
+ {bounty.type === "MULTI_WINNER_MILESTONE" && + (() => { + const occupied = bounty.totalSlotsOccupied ?? 0; + const max = bounty.maxSlots ?? 5; + return ( +
+ + Slots + + + {occupied} / {max} + +
+ ); + })()} @@ -119,6 +142,36 @@ export function SidebarCTA({ bounty, onCancelled }: SidebarCTAProps) { {/* CTA */} {isFcfs ? ( + ) : bounty.type === "MULTI_WINNER_MILESTONE" ? ( + (() => { + const occupied = bounty.totalSlotsOccupied ?? 0; + const max = bounty.maxSlots ?? 5; + const isFull = occupied >= max; + return ( + + ); + })() ) : ( + + + )} + + {isCompleted && ( +
+ Completed +
+ )} + + {isLocked && ( +
+ Locked +
+ )} + + + ); + })} + + + + ); +} diff --git a/components/bounty-detail/model4-maintainer-dashboard.tsx b/components/bounty-detail/model4-maintainer-dashboard.tsx new file mode 100644 index 00000000..c9465342 --- /dev/null +++ b/components/bounty-detail/model4-maintainer-dashboard.tsx @@ -0,0 +1,277 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Milestone, ContributorProgress } from "@/types/bounty"; +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + ChevronRight, + UserMinus, + Loader2, + MessageSquare, + Coins, + ArrowRight, + Trophy, +} from "lucide-react"; + +interface Model4MaintainerDashboardProps { + milestones: Milestone[]; + contributors: ContributorProgress[]; + maxSlots?: number; + className?: string; +} + +export function Model4MaintainerDashboard({ + milestones, + contributors: initialContributors, + maxSlots = 5, + className, +}: Model4MaintainerDashboardProps) { + const [loadingAction, setLoadingAction] = React.useState(null); + + const handleAction = async (action: string, userName: string) => { + setLoadingAction(`${action}-${userName}`); + console.log(`[Coming soon] ${action} for ${userName}`); + await new Promise((r) => setTimeout(r, 1000)); + setLoadingAction(null); + }; + + return ( + + + + Maintainer Dashboard + + Model 4 Management + + + + +
+ {initialContributors.map((contributor) => { + const currentMilestone = milestones.find( + (m) => m.id === contributor.currentMilestoneId, + ); + const currentMilestoneIndex = milestones.findIndex( + (m) => m.id === contributor.currentMilestoneId, + ); + const progressPercentage = + milestones.length === 0 + ? 0 + : Math.max( + 0, + Math.min( + 100, + ((currentMilestoneIndex + 1) / milestones.length) * 100, + ), + ); + + return ( +
+
+ {/* Contributor Info */} +
+ + + + {contributor.userName.substring(0, 2).toUpperCase()} + + +
+
+ {contributor.userName} +
+
+ + Current: + + + {currentMilestone?.title} + +
+
+
+ + {/* Progress Stats */} +
+
+ + Progress + + + {Math.round(progressPercentage)}% + +
+
+
+
+
+ + {/* Actions */} +
+ + + + + + + Send Message [Coming soon] + + + + + + + + Review work + + + + + + + Pay for milestone + + + + + + + Move to next milestone + + + + + + + + Remove from slot [Coming soon] + + + +
+
+
+ ); + })} +
+ + {/* Footer info */} +
+
+ + + Total Winners Allowed: {initialContributors.length} / {maxSlots} + +
+
+ + + + + + + + Coming soon + + +
+ + + ); +} diff --git a/components/bounty/bounty-header.tsx b/components/bounty/bounty-header.tsx index 29e99875..49f70991 100644 --- a/components/bounty/bounty-header.tsx +++ b/components/bounty/bounty-header.tsx @@ -22,6 +22,11 @@ const typeConfig: Record< icon: , className: "bg-destructive text-white border-transparent", }, + MULTI_WINNER_MILESTONE: { + label: "Multi-Winner Milestone", + icon: , + className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", + }, }; const statusConfig: Record = diff --git a/components/bounty/github-bounty-card.tsx b/components/bounty/github-bounty-card.tsx index f8a96abf..bdc64e31 100644 --- a/components/bounty/github-bounty-card.tsx +++ b/components/bounty/github-bounty-card.tsx @@ -37,6 +37,11 @@ const typeConfig: Record< icon: , className: "bg-error-500 text-white border-transparent", }, + MULTI_WINNER_MILESTONE: { + label: "Multi-Winner Milestone", + icon: , + className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", + }, }; const statusColors: Record = { diff --git a/components/bounty/milestone-funnel.tsx b/components/bounty/milestone-funnel.tsx new file mode 100644 index 00000000..968979c2 --- /dev/null +++ b/components/bounty/milestone-funnel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import React from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { CheckCircle2, Circle } from "lucide-react"; +import type { Milestone, ContributorProgress } from "@/types/bounty"; + +interface MilestoneFunnelProps { + milestones: Milestone[]; + contributors: ContributorProgress[]; + className?: string; +} + +export function MilestoneFunnel({ + milestones, + contributors, + className, +}: MilestoneFunnelProps) { + if (!milestones.length) return null; + + return ( +
+
+ {/* Progress Line Background */} +
+ + {milestones.map((milestone, index) => { + const milestoneContributors = contributors.filter( + (c) => c.currentMilestoneId === milestone.id, + ); + const isLast = index === milestones.length - 1; + + return ( +
+ {/* Milestone Indicator */} +
+ {milestone.isCompleted ? ( + + ) : ( + + )} +
+ + {/* Milestone Title & Description */} +
+

+ {milestone.title} +

+ {milestone.description && ( +

+ {milestone.description} +

+ )} +
+ + {/* Contributor Avatars */} +
+ + {milestoneContributors.length > 0 ? ( + milestoneContributors.map((contributor) => ( + + +
+ + + + {contributor.userName + .substring(0, 2) + .toUpperCase()} + + +
+
+ + +

{contributor.userName}

+

+ Current Milestone +

+
+ + )) + ) : ( +
+ None +
+ )} + +
+ + {/* Connective Line (Active State) */} + {!isLast && milestone.isCompleted && ( +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/components/projects/project-bounties.tsx b/components/projects/project-bounties.tsx index 1a2c9376..61a6f2b1 100644 --- a/components/projects/project-bounties.tsx +++ b/components/projects/project-bounties.tsx @@ -14,11 +14,18 @@ interface ProjectBountiesProps { projectId: string; } -const bountyTypes: { value: BountyType | "all"; label: string }[] = [ +const bountyTypes: { + value: BountyType | "all" | "MULTI_WINNER_MILESTONE"; + label: string; +}[] = [ { value: "all", label: "All Types" }, { value: BountyType.FixedPrice, label: "Fixed Price" }, { value: BountyType.MilestoneBased, label: "Milestone" }, { value: BountyType.Competition, label: "Competition" }, + { + value: "MULTI_WINNER_MILESTONE" as unknown as BountyType, + label: "Multi-Winner", + }, ]; const statuses: { value: BountyStatus | "all"; label: string }[] = [ @@ -88,7 +95,9 @@ export function ProjectBounties({ projectId }: ProjectBountiesProps) { >