diff --git a/app/me/bounties/page.tsx b/app/me/bounties/page.tsx new file mode 100644 index 000000000..8ac5b6acc --- /dev/null +++ b/app/me/bounties/page.tsx @@ -0,0 +1,5 @@ +import MyBountiesPage from '@/components/me/bounties/MyBountiesPage'; + +export default function MyBountiesRoutePage() { + return ; +} diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx index d2ed4d15e..3ece7209e 100644 --- a/app/me/earnings/page.tsx +++ b/app/me/earnings/page.tsx @@ -104,6 +104,20 @@ const ActivityItem: React.FC = ({ activity }) => ( {activity.source} {new Date(activity.occurredAt).toLocaleDateString()} + {/* Reward-receipt tx (txHash arrives with backend #335; cast until codegen). */} + {(activity as { txHash?: string }).txHash && ( + <> + + + View tx + + + )}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 8f167d813..eaf133279 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -9,6 +9,7 @@ import { IconUserCircle, IconUsers, IconRocket, + IconTarget, } from '@tabler/icons-react'; import { NavMain } from '@/components/nav-main'; @@ -67,6 +68,13 @@ const getNavigationData = (counts?: { : undefined, }, ], + bounties: [ + { + title: 'My Bounties', + url: '/me/bounties', + icon: IconTarget, + }, + ], account: [ { title: 'Profile', @@ -151,6 +159,7 @@ export function AppSidebar({ + {/* Footer with User */} diff --git a/components/me/bounties/MyBountiesPage.tsx b/components/me/bounties/MyBountiesPage.tsx new file mode 100644 index 000000000..233acdad2 --- /dev/null +++ b/components/me/bounties/MyBountiesPage.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { ExternalLink, Loader2 } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import EmptyState from '@/components/EmptyState'; +import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema'; +import { + useMyBountyApplications, + useMyBountySubmissions, + type BountyActivitySummary, + type MyBountyApplicationRow, + type MyBountySubmissionRow, +} from '@/features/bounties'; + +type Tab = 'applications' | 'submissions'; + +const APP_STATUS: Record = { + SUBMITTED: { label: 'Submitted', className: 'bg-blue-500/10 text-blue-400' }, + SHORTLISTED: { + label: 'Shortlisted', + className: 'bg-amber-500/10 text-amber-400', + }, + SELECTED: { + label: 'Selected', + className: 'bg-emerald-500/10 text-emerald-400', + }, + DECLINED: { label: 'Not selected', className: 'bg-red-500/10 text-red-400' }, + WITHDRAWN: { label: 'Withdrawn', className: 'bg-zinc-700/60 text-zinc-300' }, +}; + +/** Map a submission's review status to a builder-facing progress label. */ +function submissionProgress(s: MyBountySubmissionRow): { + label: string; + className: string; +} { + if (s.tierPosition != null) + return { label: 'Won', className: 'bg-emerald-500/10 text-emerald-400' }; + switch (s.status) { + case 'in_review': + case 'under_review': + return { label: 'In review', className: 'bg-blue-500/10 text-blue-400' }; + case 'accepted': + case 'approved': + return { + label: 'Accepted', + className: 'bg-emerald-500/10 text-emerald-400', + }; + case 'rejected': + case 'closed': + return { label: 'Closed', className: 'bg-zinc-700/60 text-zinc-300' }; + default: + return { label: 'Submitted', className: 'bg-blue-500/10 text-blue-400' }; + } +} + +function mode(b: BountyActivitySummary): string { + return b.entryType && b.claimType + ? computeBountyModeLabel(b.entryType, b.claimType) + : 'Bounty'; +} + +const fmtDate = (iso: string | null): string => + iso + ? new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : '—'; + +export default function MyBountiesPage() { + const [tab, setTab] = useState('applications'); + const applications = useMyBountyApplications(); + const submissions = useMyBountySubmissions(); + + const active = tab === 'applications' ? applications : submissions; + const rows = + tab === 'applications' + ? (applications.data?.items ?? []) + : (submissions.data?.items ?? []); + + return ( +
+

+ My bounties +

+

+ Track your applications, submissions, and results. +

+ + {/* Tabs */} +
+ {(['applications', 'submissions'] as Tab[]).map(t => ( + + ))} +
+ + {active.isLoading ? ( +
+ +
+ ) : rows.length === 0 ? ( +
+ +
+ ) : ( +
+ {tab === 'applications' + ? (rows as MyBountyApplicationRow[]).map(row => ( + + )) + : (rows as MyBountySubmissionRow[]).map(row => ( + + ))} +
+ )} +
+ ); +} + +function ApplicationRow({ row }: { row: MyBountyApplicationRow }) { + const status = row.applicationStatus + ? (APP_STATUS[row.applicationStatus] ?? { + label: row.applicationStatus, + className: 'bg-zinc-700/60 text-zinc-300', + }) + : { label: 'Claimed', className: 'bg-emerald-500/10 text-emerald-400' }; + + return ( + +
+

+ {row.bounty.title} +

+

+ {mode(row.bounty)} · applied {fmtDate(row.createdAt)} +

+
+ + {status.label} + + + ); +} + +function SubmissionRow({ row }: { row: MyBountySubmissionRow }) { + const progress = submissionProgress(row); + const won = row.tierPosition != null && row.tierAmount; + + return ( +
+ +

+ {row.bounty.title} +

+

+ {mode(row.bounty)} · submitted {fmtDate(row.createdAt)} +

+ + +
+ {won && ( + + {Number(row.tierAmount).toLocaleString()}{' '} + {row.bounty.rewardCurrency} + + )} + {row.rewardTransactionHash && ( + + tx + + + )} + + {progress.label} + +
+
+ ); +} diff --git a/features/bounties/api/participant-dashboard-client.ts b/features/bounties/api/participant-dashboard-client.ts new file mode 100644 index 000000000..b9fa5cee9 --- /dev/null +++ b/features/bounties/api/participant-dashboard-client.ts @@ -0,0 +1,52 @@ +/** + * Builder "my bounties" dashboard reads (boundless-nestjs #332). + * + * These endpoints aren't in the generated OpenAPI schema yet, so they go through + * the legacy axios `api` (cookie auth, `{ success, data }` envelope) rather than + * the typed openapi-fetch client. Swap to the typed client + generated types once + * #332 lands and `npm run codegen` runs. Until the backend ships, calls 404 and + * the hooks degrade to empty. + */ +import { api } from '@/lib/api/api'; + +import type { + BountyActivityPage, + MyBountyApplicationRow, + MyBountySubmissionRow, +} from '../types'; + +export interface MyActivityParams { + status?: string; + page?: number; + limit?: number; +} + +/** Unwrap the `{ success, data }` envelope (some routes return the payload bare). */ +function unwrap(res: { data: unknown }): T { + const body = res.data as { data?: T }; + return ( + body && typeof body === 'object' && 'data' in body + ? body.data + : (res.data as T) + ) as T; +} + +/** The legacy `api` RequestConfig has no `params`, so encode the query inline. */ +function withQuery(path: string, params: MyActivityParams): string { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.page != null) q.set('page', String(params.page)); + if (params.limit != null) q.set('limit', String(params.limit)); + const s = q.toString(); + return s ? `${path}?${s}` : path; +} + +export const listMyBountyApplications = async ( + params: MyActivityParams = {} +): Promise> => + unwrap(await api.get(withQuery('/bounties/me/applications', params))); + +export const listMyBountySubmissions = async ( + params: MyActivityParams = {} +): Promise> => + unwrap(await api.get(withQuery('/bounties/me/submissions', params))); diff --git a/features/bounties/api/use-participant-dashboard.ts b/features/bounties/api/use-participant-dashboard.ts new file mode 100644 index 000000000..1ec848092 --- /dev/null +++ b/features/bounties/api/use-participant-dashboard.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { bountyKeys } from './keys'; +import { + listMyBountyApplications, + listMyBountySubmissions, + type MyActivityParams, +} from './participant-dashboard-client'; + +/** + * Builder "my bounties" activity reads (boundless-nestjs #332). Until that + * backend lands the requests 404; React Query surfaces the error and the UI + * shows an empty/coming-soon state. `retry: false` keeps a missing endpoint + * from spamming retries. + */ +export function useMyBountyApplications(params: MyActivityParams = {}) { + return useQuery({ + queryKey: [...bountyKeys.myActivity(), 'applications', params], + queryFn: () => listMyBountyApplications(params), + retry: false, + }); +} + +export function useMyBountySubmissions(params: MyActivityParams = {}) { + return useQuery({ + queryKey: [...bountyKeys.myActivity(), 'submissions', params], + queryFn: () => listMyBountySubmissions(params), + retry: false, + }); +} diff --git a/features/bounties/index.ts b/features/bounties/index.ts index 2273615b9..6ffd9b211 100644 --- a/features/bounties/index.ts +++ b/features/bounties/index.ts @@ -130,6 +130,23 @@ export { submitSignedParticipantBounty, } from './api/participant-escrow-client'; +// Builder "my bounties" dashboard (#332 reads; hand-typed until codegen). +export type { + BountyActivitySummary, + MyBountyApplicationRow, + MyBountySubmissionRow, + BountyActivityPage, +} from './types'; +export { + listMyBountyApplications, + listMyBountySubmissions, +} from './api/participant-dashboard-client'; +export type { MyActivityParams } from './api/participant-dashboard-client'; +export { + useMyBountyApplications, + useMyBountySubmissions, +} from './api/use-participant-dashboard'; + // Participant hooks (React Query). export { useBountiesList, diff --git a/features/bounties/types.ts b/features/bounties/types.ts index 0727334c0..330892790 100644 --- a/features/bounties/types.ts +++ b/features/bounties/types.ts @@ -129,3 +129,49 @@ export interface MyBountyApplication { createdAt: string; updatedAt: string; } + +/** + * Cross-bounty "my activity" dashboard rows (boundless-nestjs #332). These + * endpoints (`/bounties/me/applications`, `/bounties/me/submissions`) are not + * yet in the generated schema, so the rows are hand-typed to the backend DTOs. + * Swap to `Schemas[...]` once #332 lands and codegen runs. + */ +export interface BountyActivitySummary { + id: string; + title: string; + status: string; + entryType: BountyEntryType | null; + claimType: BountyClaimType | null; + rewardCurrency: string; + deadline: string | null; +} + +export interface MyBountyApplicationRow { + id: string; + applicationStatus: BountyApplicationStatus | null; + status: string; + createdAt: string; + updatedAt: string; + bounty: BountyActivitySummary; +} + +export interface MyBountySubmissionRow { + id: string; + status: string; + escrowAnchorStatus: string | null; + githubPullRequestUrl: string | null; + tierPosition: number | null; + tierAmount: string | null; + rewardTransactionHash: string | null; + paidAt: string | null; + createdAt: string; + updatedAt: string; + bounty: BountyActivitySummary; +} + +export interface BountyActivityPage { + items: T[]; + total: number; + page: number; + limit: number; +}