diff --git a/app/api/bounties/[id]/route.ts b/app/api/bounties/[id]/route.ts index cd82b52..b8c1f5c 100644 --- a/app/api/bounties/[id]/route.ts +++ b/app/api/bounties/[id]/route.ts @@ -1,24 +1,24 @@ -import { NextResponse } from "next/server"; -import { getBountyById } from "@/lib/mock-bounty"; -import { BountyLogic } from "@/lib/logic/bounty-logic"; - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - // Simulate network delay in development only - if (process.env.NODE_ENV === "development") { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - const { id } = await params; - const bounty = getBountyById(id); - - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - const processed = BountyLogic.processBountyStatus(bounty); - - return NextResponse.json(processed); -} +import { NextResponse } from "next/server"; +import { getBountyById } from "@/lib/mock-bounty"; +import { BountyLogic } from "@/lib/logic/bounty-logic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + // Simulate network delay in development only + if (process.env.NODE_ENV === "development") { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const { id } = await params; + const bounty = getBountyById(id); + + if (!bounty) { + return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); + } + + const processed = BountyLogic.processBountyStatus(bounty); + + return NextResponse.json(processed); +} diff --git a/app/bounty/[bountyId]/loading.tsx b/app/bounty/[bountyId]/loading.tsx index 26b3ba0..23694dc 100644 --- a/app/bounty/[bountyId]/loading.tsx +++ b/app/bounty/[bountyId]/loading.tsx @@ -1,5 +1,5 @@ -import { BountySkeleton } from "@/components/bounty/bounty-skeleton" +import { BountySkeleton } from "@/components/bounty/bounty-skeleton"; export default function Loading() { - return + return ; } diff --git a/app/bounty/[bountyId]/not-found.tsx b/app/bounty/[bountyId]/not-found.tsx index 9253642..5cb0e35 100644 --- a/app/bounty/[bountyId]/not-found.tsx +++ b/app/bounty/[bountyId]/not-found.tsx @@ -1,6 +1,6 @@ -import { Button } from "@/components/ui/button" -import { FileQuestion } from "lucide-react" -import Link from "next/link" +import { Button } from "@/components/ui/button"; +import { FileQuestion } from "lucide-react"; +import Link from "next/link"; export default function NotFound() { return ( @@ -10,13 +10,17 @@ export default function NotFound() {

Bounty Not Found

- The bounty you're looking for doesn't exist or has been removed. + The bounty you're looking for doesn't exist or has been + removed.

- - ) + ); } diff --git a/app/profile/[userId]/page.tsx b/app/profile/[userId]/page.tsx index 77f8d29..743a525 100644 --- a/app/profile/[userId]/page.tsx +++ b/app/profile/[userId]/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useContributorReputation } from "@/hooks/use-reputation"; +import { useBounties } from "@/hooks/use-bounties"; import { ReputationCard } from "@/components/reputation/reputation-card"; import { CompletionHistory } from "@/components/reputation/completion-history"; +import { MyClaims, type MyClaim } from "@/components/reputation/my-claims"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -15,6 +17,7 @@ export default function ProfilePage() { const params = useParams(); const userId = params.userId as string; const { data: reputation, isLoading, error } = useContributorReputation(userId); + const { data: bountyResponse } = useBounties(); const MAX_MOCK_HISTORY = 50; @@ -39,13 +42,39 @@ export default function ProfilePage() { })); }, [reputation]); + const myClaims = useMemo(() => { + const bounties = bountyResponse?.data ?? []; + + return bounties + .filter((bounty) => bounty.claimedBy === userId) + .map((bounty) => { + let status = "active"; + + if (bounty.status === "closed") { + status = "completed"; + } else if (bounty.status === "claimed" && bounty.claimExpiresAt) { + const claimExpiry = new Date(bounty.claimExpiresAt); + if (!Number.isNaN(claimExpiry.getTime()) && claimExpiry < new Date()) { + status = "in-review"; + } + } + + return { + bountyId: bounty.id, + title: bounty.issueTitle, + status, + rewardAmount: bounty.rewardAmount ?? undefined, + }; + }); + }, [bountyResponse?.data, userId]); + if (isLoading) { return (
- - + +
); @@ -134,6 +163,12 @@ export default function ProfilePage() { > Analytics + + My Claims + @@ -149,6 +184,11 @@ export default function ProfilePage() { Detailed analytics coming soon. + + +

My Claims

+ +
diff --git a/components/bounty-detail/bounty-badges.tsx b/components/bounty-detail/bounty-badges.tsx index bbba268..30c8b15 100644 --- a/components/bounty-detail/bounty-badges.tsx +++ b/components/bounty-detail/bounty-badges.tsx @@ -1,31 +1,31 @@ -import { Zap } from "lucide-react"; -import type { Bounty } from "@/lib/api"; -import { DIFFICULTY_CONFIG, STATUS_CONFIG } from "@/lib/bounty-config"; - -export function StatusBadge({ status }: { status: Bounty["status"] }) { - const cfg = STATUS_CONFIG[status]; - return ( - - - {cfg.label} - - ); -} - -export function DifficultyBadge({ - difficulty, -}: { - difficulty: NonNullable; -}) { - const cfg = DIFFICULTY_CONFIG[difficulty]; - return ( - - - {cfg.label} - - ); -} +import { Zap } from "lucide-react"; +import type { Bounty } from "@/lib/api"; +import { DIFFICULTY_CONFIG, STATUS_CONFIG } from "@/lib/bounty-config"; + +export function StatusBadge({ status }: { status: Bounty["status"] }) { + const cfg = STATUS_CONFIG[status]; + return ( + + + {cfg.label} + + ); +} + +export function DifficultyBadge({ + difficulty, +}: { + difficulty: NonNullable; +}) { + const cfg = DIFFICULTY_CONFIG[difficulty]; + return ( + + + {cfg.label} + + ); +} diff --git a/components/bounty-detail/bounty-detail-bounty-detail-skeleton.tsx b/components/bounty-detail/bounty-detail-bounty-detail-skeleton.tsx index 0d5ac3c..02eccfc 100644 --- a/components/bounty-detail/bounty-detail-bounty-detail-skeleton.tsx +++ b/components/bounty-detail/bounty-detail-bounty-detail-skeleton.tsx @@ -1,59 +1,59 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export function BountyDetailSkeleton() { - return ( -
-
-
- -
-
-
-
- - - -
- - -
- {[1, 2, 3].map((i) => ( - - ))} -
-
-
- - {[1, 2, 3, 4, 5].map((i) => ( - - ))} -
-
-
-
-
- - -
- - {[1, 2, 3].map((i) => ( -
- - -
- ))} - - -
-
-
-
-
- ); -} +import { Skeleton } from "@/components/ui/skeleton"; + +export function BountyDetailSkeleton() { + return ( +
+
+
+ +
+
+
+
+ + + +
+ + +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ + {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+
+
+
+ + +
+ + {[1, 2, 3].map((i) => ( +
+ + +
+ ))} + + +
+
+
+
+
+ ); +} diff --git a/components/bounty-detail/bounty-detail-description-card.tsx b/components/bounty-detail/bounty-detail-description-card.tsx index 1424ec7..bedce3e 100644 --- a/components/bounty-detail/bounty-detail-description-card.tsx +++ b/components/bounty-detail/bounty-detail-description-card.tsx @@ -1,31 +1,31 @@ -"use client"; - -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; - -export function DescriptionCard({ description }: { description: string }) { - return ( -
-

- Description -

-
- {description} -
-
- ); -} +"use client"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export function DescriptionCard({ description }: { description: string }) { + return ( +
+

+ Description +

+
+ {description} +
+
+ ); +} diff --git a/components/bounty-detail/bounty-detail-header-card.tsx b/components/bounty-detail/bounty-detail-header-card.tsx index 8029ffd..5bc88cc 100644 --- a/components/bounty-detail/bounty-detail-header-card.tsx +++ b/components/bounty-detail/bounty-detail-header-card.tsx @@ -1,98 +1,98 @@ -import Link from "next/link"; -import { ExternalLink, Tag, GitBranch } from "lucide-react"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import type { Bounty } from "@/lib/api"; -import { DifficultyBadge, StatusBadge } from "./bounty-badges"; - -export function HeaderCard({ bounty }: { bounty: Bounty }) { - return ( -
- {/* Badges */} -
- - {bounty.difficulty && ( - - )} - - {bounty.type} - -
- - {/* Title */} -

- {bounty.issueTitle} -

- - {/* Repo + issue number */} -
- - {bounty.githubRepo} - · - - #{bounty.issueNumber} - - -
- - {/* Reward – mobile only */} -
- - Reward - - - {bounty.rewardAmount != null - ? `$${bounty.rewardAmount.toLocaleString()}` - : "TBD"} - - {bounty.rewardCurrency} - - -
- - {/* Project */} -
- - - - {bounty.projectName.slice(0, 2).toUpperCase()} - - -
-

- Project -

- - {bounty.projectName} - - -
-
- - {/* Tags */} - {bounty.tags.length > 0 && ( -
- - {bounty.tags.map((tag) => ( - - {tag} - - ))} -
- )} -
- ); -} +import Link from "next/link"; +import { ExternalLink, Tag, GitBranch } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import type { Bounty } from "@/lib/api"; +import { DifficultyBadge, StatusBadge } from "./bounty-badges"; + +export function HeaderCard({ bounty }: { bounty: Bounty }) { + return ( +
+ {/* Badges */} +
+ + {bounty.difficulty && ( + + )} + + {bounty.type} + +
+ + {/* Title */} +

+ {bounty.issueTitle} +

+ + {/* Repo + issue number */} +
+ + {bounty.githubRepo} + · + + #{bounty.issueNumber} + + +
+ + {/* Reward – mobile only */} +
+ + Reward + + + {bounty.rewardAmount != null + ? `$${bounty.rewardAmount.toLocaleString()}` + : "TBD"} + + {bounty.rewardCurrency} + + +
+ + {/* Project */} +
+ + + + {bounty.projectName.slice(0, 2).toUpperCase()} + + +
+

+ Project +

+ + {bounty.projectName} + + +
+
+ + {/* Tags */} + {bounty.tags.length > 0 && ( +
+ + {bounty.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ); +} diff --git a/components/bounty-detail/bounty-detail-requirements-card.tsx b/components/bounty-detail/bounty-detail-requirements-card.tsx index 59ac673..d6f99a1 100644 --- a/components/bounty-detail/bounty-detail-requirements-card.tsx +++ b/components/bounty-detail/bounty-detail-requirements-card.tsx @@ -1,36 +1,36 @@ -import type { Bounty } from "@/lib/api"; - -export function RequirementsCard({ - requirements, -}: { - requirements: NonNullable; -}) { - if (requirements.length === 0) return null; - - return ( -
-

- Requirements -

-
    - {requirements.map((req, i) => ( -
  • - - {req} -
  • - ))} -
-
- ); -} - -export function ScopeCard({ scope }: { scope: string }) { - return ( -
-

- Scope -

-

{scope}

-
- ); -} +import type { Bounty } from "@/lib/api"; + +export function RequirementsCard({ + requirements, +}: { + requirements: NonNullable; +}) { + if (requirements.length === 0) return null; + + return ( +
+

+ Requirements +

+
    + {requirements.map((req, i) => ( +
  • + + {req} +
  • + ))} +
+
+ ); +} + +export function ScopeCard({ scope }: { scope: string }) { + return ( +
+

+ Scope +

+

{scope}

+
+ ); +} diff --git a/components/bounty-detail/bounty-detail-sidebar-cta.tsx b/components/bounty-detail/bounty-detail-sidebar-cta.tsx index a468597..a26cf5d 100644 --- a/components/bounty-detail/bounty-detail-sidebar-cta.tsx +++ b/components/bounty-detail/bounty-detail-sidebar-cta.tsx @@ -1,205 +1,205 @@ -"use client"; - -import { useState } from "react"; -import { Github, Copy, Check, AlertCircle, Clock } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; - -import type { Bounty } from "@/lib/api"; -import { DifficultyBadge, StatusBadge } from "./bounty-badges"; -import { CLAIMING_MODEL_CONFIG } from "@/lib/bounty-config"; - -export function SidebarCTA({ bounty }: { bounty: Bounty }) { - const [copied, setCopied] = useState(false); - const canAct = bounty.status === "open"; - const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; - const ClaimIcon = claimCfg.icon; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(window.location.href); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // clipboard write failed (e.g. non-HTTPS, permission denied) - } - }; - - const ctaLabel = () => { - if (!canAct) - return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; - switch (bounty.claimingModel) { - case "single-claim": - return "Claim Bounty"; - case "application": - return "Apply Now"; - case "competition": - return "Submit Entry"; - case "multi-winner": - return "Submit Work"; - } - }; - - return ( -
- {/* Reward */} -
- - Reward - -
-

- {bounty.rewardAmount != null - ? `$${bounty.rewardAmount.toLocaleString()}` - : "TBD"} -

-

- {bounty.rewardCurrency} -

-
-
- - - - {/* Meta */} -
-
- Status - -
-
- Model - - - {claimCfg.label} - -
- {bounty.difficulty && ( -
- Difficulty - -
- )} - {bounty.submissionsEndDate && ( -
- Deadline - - - {new Date(bounty.submissionsEndDate).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - -
- )} -
- - - - {/* CTA */} - - - {!canAct && ( -

- - {bounty.status === "claimed" - ? "A contributor has already claimed this bounty." - : "This bounty is no longer accepting submissions."} -

- )} - - {/* GitHub */} - - - View on GitHub - - - {/* Copy link */} - -
- ); -} - -export function ClaimModelInfo({ - claimingModel, -}: { - claimingModel: Bounty["claimingModel"]; -}) { - return ( -
-

- Claim Model -

-

- {CLAIMING_MODEL_CONFIG[claimingModel].description} -

-
- ); -} - -export function MobileCTA({ bounty }: { bounty: Bounty }) { - const canAct = bounty.status === "open"; - // const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; - - const label = () => { - if (!canAct) - return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; - switch (bounty.claimingModel) { - case "single-claim": - return "Claim Bounty"; - case "application": - return "Apply Now"; - case "competition": - return "Submit Entry"; - case "multi-winner": - return "Submit Work"; - } - }; - - return ( -
- -
- ); -} +"use client"; + +import { useState } from "react"; +import { Github, Copy, Check, AlertCircle, Clock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + +import type { Bounty } from "@/lib/api"; +import { DifficultyBadge, StatusBadge } from "./bounty-badges"; +import { CLAIMING_MODEL_CONFIG } from "@/lib/bounty-config"; + +export function SidebarCTA({ bounty }: { bounty: Bounty }) { + const [copied, setCopied] = useState(false); + const canAct = bounty.status === "open"; + const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; + const ClaimIcon = claimCfg.icon; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard write failed (e.g. non-HTTPS, permission denied) + } + }; + + const ctaLabel = () => { + if (!canAct) + return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; + switch (bounty.claimingModel) { + case "single-claim": + return "Claim Bounty"; + case "application": + return "Apply Now"; + case "competition": + return "Submit Entry"; + case "multi-winner": + return "Submit Work"; + } + }; + + return ( +
+ {/* Reward */} +
+ + Reward + +
+

+ {bounty.rewardAmount != null + ? `$${bounty.rewardAmount.toLocaleString()}` + : "TBD"} +

+

+ {bounty.rewardCurrency} +

+
+
+ + + + {/* Meta */} +
+
+ Status + +
+
+ Model + + + {claimCfg.label} + +
+ {bounty.difficulty && ( +
+ Difficulty + +
+ )} + {bounty.submissionsEndDate && ( +
+ Deadline + + + {new Date(bounty.submissionsEndDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + +
+ )} +
+ + + + {/* CTA */} + + + {!canAct && ( +

+ + {bounty.status === "claimed" + ? "A contributor has already claimed this bounty." + : "This bounty is no longer accepting submissions."} +

+ )} + + {/* GitHub */} + + + View on GitHub + + + {/* Copy link */} + +
+ ); +} + +export function ClaimModelInfo({ + claimingModel, +}: { + claimingModel: Bounty["claimingModel"]; +}) { + return ( +
+

+ Claim Model +

+

+ {CLAIMING_MODEL_CONFIG[claimingModel].description} +

+
+ ); +} + +export function MobileCTA({ bounty }: { bounty: Bounty }) { + const canAct = bounty.status === "open"; + // const claimCfg = CLAIMING_MODEL_CONFIG[bounty.claimingModel]; + + const label = () => { + if (!canAct) + return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed"; + switch (bounty.claimingModel) { + case "single-claim": + return "Claim Bounty"; + case "application": + return "Apply Now"; + case "competition": + return "Submit Entry"; + case "multi-winner": + return "Submit Work"; + } + }; + + return ( +
+ +
+ ); +} diff --git a/components/bounty/sponsor-review-dashboard.tsx b/components/bounty/sponsor-review-dashboard.tsx index d6b8fe6..5118d19 100644 --- a/components/bounty/sponsor-review-dashboard.tsx +++ b/components/bounty/sponsor-review-dashboard.tsx @@ -1,98 +1,169 @@ -"use client" +"use client"; -import * as React from "react" -import { format, parseISO } from "date-fns" -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" -import { ReviewSubmission } from "@/types/participation" +import * as React from "react"; +import { format, parseISO } from "date-fns"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { ReviewSubmission } from "@/types/participation"; -type Action = 'approve' | 'reject' | 'request_revision' +type Action = "approve" | "reject" | "request_revision"; interface SponsorReviewDashboardProps { - submissions: ReviewSubmission[] - onAction?: (submissionId: string, action: Action) => Promise | void + submissions: ReviewSubmission[]; + onAction?: (submissionId: string, action: Action) => Promise | void; } -export function SponsorReviewDashboard({ submissions, onAction }: SponsorReviewDashboardProps) { - const [items, setItems] = React.useState(() => submissions) - const [loadingIds, setLoadingIds] = React.useState>({}) +export function SponsorReviewDashboard({ + submissions, + onAction, +}: SponsorReviewDashboardProps) { + const [items, setItems] = React.useState( + () => submissions, + ); + const [loadingIds, setLoadingIds] = React.useState>( + {}, + ); React.useEffect(() => { - setItems(curr => { - const currIdMap = new Map(curr.map(it => [it.submissionId, it])) - return submissions.map(sub => (currIdMap.get(sub.submissionId) ?? sub) as ReviewSubmission) - }) - }, [submissions]) + setItems((curr) => { + const currIdMap = new Map(curr.map((it) => [it.submissionId, it])); + return submissions.map( + (sub) => (currIdMap.get(sub.submissionId) ?? sub) as ReviewSubmission, + ); + }); + }, [submissions]); const handleAction = async (id: string, action: Action) => { - setLoadingIds(s => ({ ...s, [id]: true })) - let prevItem: ReviewSubmission | undefined + setLoadingIds((s) => ({ ...s, [id]: true })); + let prevItem: ReviewSubmission | undefined; - setItems(curr => curr.map(it => { - if (it.submissionId === id) { - prevItem = it - return { - ...it, - status: action === 'approve' ? 'approved' : action === 'reject' ? 'rejected' : 'revision_requested' + setItems((curr) => + curr.map((it) => { + if (it.submissionId === id) { + prevItem = it; + return { + ...it, + status: + action === "approve" + ? "approved" + : action === "reject" + ? "rejected" + : "revision_requested", + }; } - } - return it - })) + return it; + }), + ); try { - const maybe = onAction && onAction(id, action) - if (maybe && maybe instanceof Promise) await maybe - } catch (err) { + const maybe = onAction && onAction(id, action); + if (maybe && maybe instanceof Promise) await maybe; + } catch { if (prevItem) { - setItems(curr => curr.map(it => (it.submissionId === id ? prevItem : it)) as ReviewSubmission[]) + setItems( + (curr) => + curr.map((it) => + it.submissionId === id ? prevItem : it, + ) as ReviewSubmission[], + ); } } finally { - setLoadingIds(s => { - const copy = { ...s } - delete copy[id] - return copy - }) + setLoadingIds((s) => { + const copy = { ...s }; + delete copy[id]; + return copy; + }); } - } + }; return (
- {items.length === 0 &&
No submissions to review.
} + {items.length === 0 && ( +
+ No submissions to review. +
+ )}
    - {items.map(sub => ( -
  • + {items.map((sub) => ( +
  • {sub.contributor.avatarUrl ? ( - + ) : ( - {sub.contributor.username?.charAt(0).toUpperCase() ?? "?"} + + {sub.contributor.username?.charAt(0).toUpperCase() ?? "?"} + )}
    -
    {sub.contributor.username}
    -
    Submitted {format(parseISO(sub.submittedAt), 'MM/dd/yyyy, hh:mm aa')}
    - {sub.milestoneId &&
    Milestone: {sub.milestoneId}
    } +
    + {sub.contributor.username} +
    +
    + Submitted{" "} + {format(parseISO(sub.submittedAt), "MM/dd/yyyy, hh:mm aa")} +
    + {sub.milestoneId && ( +
    + Milestone: {sub.milestoneId} +
    + )}
    - {sub.status === 'pending' && Pending} - {sub.status === 'approved' && Approved} - {sub.status === 'rejected' && Rejected} - {sub.status === 'revision_requested' && Revision Requested} + {sub.status === "pending" && ( + Pending + )} + {sub.status === "approved" && ( + Approved + )} + {sub.status === "rejected" && ( + Rejected + )} + {sub.status === "revision_requested" && ( + + Revision Requested + + )}
    - - -
    @@ -101,7 +172,7 @@ export function SponsorReviewDashboard({ submissions, onAction }: SponsorReviewD ))}
- ) + ); } -export default SponsorReviewDashboard +export default SponsorReviewDashboard; diff --git a/components/compliance/appeal-dialog.tsx b/components/compliance/appeal-dialog.tsx index 9da687b..80b8788 100644 --- a/components/compliance/appeal-dialog.tsx +++ b/components/compliance/appeal-dialog.tsx @@ -82,7 +82,7 @@ export function AppealDialog({