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;
+}