Skip to content

Commit d03d1a5

Browse files
authored
feat(bounty): builder "my bounties" activity + rewards receipt (#651)
The builder's /me surfaces for bounty applications, submissions, and reward receipts, consuming the participant dashboard (#332) + earnings (#335). - data layer: useMyBountyApplications / useMyBountySubmissions (the deferred useMyBountyActivity) via the legacy axios api (hand-typed rows, retry:false, degrading to empty until #332 lands in the schema) - /me/bounties page: Applications + Submissions tabs with status/progress badges; won submissions show the reward amount + a payout tx-hash link; rows link to the detail page - sidebar: "My Bounties" nav entry - /me/earnings: the page already renders breakdown.bounties + activities generically (so #335 surfaces them); add a "View tx" link to reward activities (txHash cast until codegen) Closes #626
1 parent c76b521 commit d03d1a5

8 files changed

Lines changed: 391 additions & 0 deletions

File tree

app/me/bounties/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import MyBountiesPage from '@/components/me/bounties/MyBountiesPage';
2+
3+
export default function MyBountiesRoutePage() {
4+
return <MyBountiesPage />;
5+
}

app/me/earnings/page.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ activity }) => (
104104
<span className='capitalize'>{activity.source}</span>
105105
<span></span>
106106
<span>{new Date(activity.occurredAt).toLocaleDateString()}</span>
107+
{/* Reward-receipt tx (txHash arrives with backend #335; cast until codegen). */}
108+
{(activity as { txHash?: string }).txHash && (
109+
<>
110+
<span></span>
111+
<a
112+
href={`https://stellar.expert/explorer/testnet/tx/${(activity as { txHash?: string }).txHash}`}
113+
target='_blank'
114+
rel='noreferrer'
115+
className='text-primary hover:underline'
116+
>
117+
View tx
118+
</a>
119+
</>
120+
)}
107121
</div>
108122
</div>
109123
<div className='text-right'>

components/app-sidebar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
IconUserCircle,
1010
IconUsers,
1111
IconRocket,
12+
IconTarget,
1213
} from '@tabler/icons-react';
1314

1415
import { NavMain } from '@/components/nav-main';
@@ -67,6 +68,13 @@ const getNavigationData = (counts?: {
6768
: undefined,
6869
},
6970
],
71+
bounties: [
72+
{
73+
title: 'My Bounties',
74+
url: '/me/bounties',
75+
icon: IconTarget,
76+
},
77+
],
7078
account: [
7179
{
7280
title: 'Profile',
@@ -151,6 +159,7 @@ export function AppSidebar({
151159
<NavMain items={navigationData.main} />
152160
<NavMain items={navigationData.crowdfunding} label='Crowdfunding' />
153161
<NavMain items={navigationData.hackathons} label='Hackathons' />
162+
<NavMain items={navigationData.bounties} label='Bounties' />
154163
<NavMain items={navigationData.account} label='Account' />
155164
</SidebarContent>
156165
{/* Footer with User */}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Link from 'next/link';
5+
import { ExternalLink, Loader2 } from 'lucide-react';
6+
7+
import { Badge } from '@/components/ui/badge';
8+
import EmptyState from '@/components/EmptyState';
9+
import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
10+
import {
11+
useMyBountyApplications,
12+
useMyBountySubmissions,
13+
type BountyActivitySummary,
14+
type MyBountyApplicationRow,
15+
type MyBountySubmissionRow,
16+
} from '@/features/bounties';
17+
18+
type Tab = 'applications' | 'submissions';
19+
20+
const APP_STATUS: Record<string, { label: string; className: string }> = {
21+
SUBMITTED: { label: 'Submitted', className: 'bg-blue-500/10 text-blue-400' },
22+
SHORTLISTED: {
23+
label: 'Shortlisted',
24+
className: 'bg-amber-500/10 text-amber-400',
25+
},
26+
SELECTED: {
27+
label: 'Selected',
28+
className: 'bg-emerald-500/10 text-emerald-400',
29+
},
30+
DECLINED: { label: 'Not selected', className: 'bg-red-500/10 text-red-400' },
31+
WITHDRAWN: { label: 'Withdrawn', className: 'bg-zinc-700/60 text-zinc-300' },
32+
};
33+
34+
/** Map a submission's review status to a builder-facing progress label. */
35+
function submissionProgress(s: MyBountySubmissionRow): {
36+
label: string;
37+
className: string;
38+
} {
39+
if (s.tierPosition != null)
40+
return { label: 'Won', className: 'bg-emerald-500/10 text-emerald-400' };
41+
switch (s.status) {
42+
case 'in_review':
43+
case 'under_review':
44+
return { label: 'In review', className: 'bg-blue-500/10 text-blue-400' };
45+
case 'accepted':
46+
case 'approved':
47+
return {
48+
label: 'Accepted',
49+
className: 'bg-emerald-500/10 text-emerald-400',
50+
};
51+
case 'rejected':
52+
case 'closed':
53+
return { label: 'Closed', className: 'bg-zinc-700/60 text-zinc-300' };
54+
default:
55+
return { label: 'Submitted', className: 'bg-blue-500/10 text-blue-400' };
56+
}
57+
}
58+
59+
function mode(b: BountyActivitySummary): string {
60+
return b.entryType && b.claimType
61+
? computeBountyModeLabel(b.entryType, b.claimType)
62+
: 'Bounty';
63+
}
64+
65+
const fmtDate = (iso: string | null): string =>
66+
iso
67+
? new Date(iso).toLocaleDateString(undefined, {
68+
month: 'short',
69+
day: 'numeric',
70+
year: 'numeric',
71+
})
72+
: '—';
73+
74+
export default function MyBountiesPage() {
75+
const [tab, setTab] = useState<Tab>('applications');
76+
const applications = useMyBountyApplications();
77+
const submissions = useMyBountySubmissions();
78+
79+
const active = tab === 'applications' ? applications : submissions;
80+
const rows =
81+
tab === 'applications'
82+
? (applications.data?.items ?? [])
83+
: (submissions.data?.items ?? []);
84+
85+
return (
86+
<div className='mx-auto max-w-5xl px-4 py-8'>
87+
<h1 className='text-2xl font-bold tracking-tight text-white'>
88+
My bounties
89+
</h1>
90+
<p className='mt-1 text-sm text-zinc-400'>
91+
Track your applications, submissions, and results.
92+
</p>
93+
94+
{/* Tabs */}
95+
<div className='mt-6 mb-4 flex gap-2'>
96+
{(['applications', 'submissions'] as Tab[]).map(t => (
97+
<button
98+
key={t}
99+
onClick={() => setTab(t)}
100+
className={`rounded-full px-4 py-2 text-sm font-medium capitalize transition-all ${
101+
tab === t
102+
? 'bg-primary text-black'
103+
: 'bg-zinc-900 text-white hover:bg-zinc-800'
104+
}`}
105+
>
106+
{t}
107+
</button>
108+
))}
109+
</div>
110+
111+
{active.isLoading ? (
112+
<div className='flex items-center justify-center py-20'>
113+
<Loader2 className='h-6 w-6 animate-spin text-zinc-500' />
114+
</div>
115+
) : rows.length === 0 ? (
116+
<div className='py-16'>
117+
<EmptyState
118+
title={`No ${tab} yet`}
119+
description={
120+
tab === 'applications'
121+
? 'Apply to a bounty and it will show up here.'
122+
: 'Submit your work on a bounty and it will show up here.'
123+
}
124+
type='compact'
125+
/>
126+
</div>
127+
) : (
128+
<div className='overflow-hidden rounded-xl border border-zinc-800'>
129+
{tab === 'applications'
130+
? (rows as MyBountyApplicationRow[]).map(row => (
131+
<ApplicationRow key={row.id} row={row} />
132+
))
133+
: (rows as MyBountySubmissionRow[]).map(row => (
134+
<SubmissionRow key={row.id} row={row} />
135+
))}
136+
</div>
137+
)}
138+
</div>
139+
);
140+
}
141+
142+
function ApplicationRow({ row }: { row: MyBountyApplicationRow }) {
143+
const status = row.applicationStatus
144+
? (APP_STATUS[row.applicationStatus] ?? {
145+
label: row.applicationStatus,
146+
className: 'bg-zinc-700/60 text-zinc-300',
147+
})
148+
: { label: 'Claimed', className: 'bg-emerald-500/10 text-emerald-400' };
149+
150+
return (
151+
<Link
152+
href={`/bounties/${row.bounty.id}`}
153+
className='flex items-center justify-between gap-4 border-b border-zinc-800 px-4 py-3 transition-colors last:border-0 hover:bg-zinc-900/50'
154+
>
155+
<div className='min-w-0'>
156+
<p className='truncate text-sm font-medium text-white'>
157+
{row.bounty.title}
158+
</p>
159+
<p className='text-xs text-zinc-500'>
160+
{mode(row.bounty)} · applied {fmtDate(row.createdAt)}
161+
</p>
162+
</div>
163+
<Badge
164+
variant='outline'
165+
className={`shrink-0 rounded-full border-transparent px-3 py-1 text-xs font-medium ${status.className}`}
166+
>
167+
{status.label}
168+
</Badge>
169+
</Link>
170+
);
171+
}
172+
173+
function SubmissionRow({ row }: { row: MyBountySubmissionRow }) {
174+
const progress = submissionProgress(row);
175+
const won = row.tierPosition != null && row.tierAmount;
176+
177+
return (
178+
<div className='flex items-center justify-between gap-4 border-b border-zinc-800 px-4 py-3 last:border-0'>
179+
<Link href={`/bounties/${row.bounty.id}`} className='min-w-0 flex-1'>
180+
<p className='truncate text-sm font-medium text-white hover:underline'>
181+
{row.bounty.title}
182+
</p>
183+
<p className='text-xs text-zinc-500'>
184+
{mode(row.bounty)} · submitted {fmtDate(row.createdAt)}
185+
</p>
186+
</Link>
187+
188+
<div className='flex shrink-0 items-center gap-3'>
189+
{won && (
190+
<span className='text-primary text-sm font-semibold'>
191+
{Number(row.tierAmount).toLocaleString()}{' '}
192+
{row.bounty.rewardCurrency}
193+
</span>
194+
)}
195+
{row.rewardTransactionHash && (
196+
<a
197+
href={`https://stellar.expert/explorer/testnet/tx/${row.rewardTransactionHash}`}
198+
target='_blank'
199+
rel='noreferrer'
200+
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
201+
title='View payout transaction'
202+
>
203+
tx
204+
<ExternalLink className='h-3 w-3' />
205+
</a>
206+
)}
207+
<Badge
208+
variant='outline'
209+
className={`rounded-full border-transparent px-3 py-1 text-xs font-medium ${progress.className}`}
210+
>
211+
{progress.label}
212+
</Badge>
213+
</div>
214+
</div>
215+
);
216+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Builder "my bounties" dashboard reads (boundless-nestjs #332).
3+
*
4+
* These endpoints aren't in the generated OpenAPI schema yet, so they go through
5+
* the legacy axios `api` (cookie auth, `{ success, data }` envelope) rather than
6+
* the typed openapi-fetch client. Swap to the typed client + generated types once
7+
* #332 lands and `npm run codegen` runs. Until the backend ships, calls 404 and
8+
* the hooks degrade to empty.
9+
*/
10+
import { api } from '@/lib/api/api';
11+
12+
import type {
13+
BountyActivityPage,
14+
MyBountyApplicationRow,
15+
MyBountySubmissionRow,
16+
} from '../types';
17+
18+
export interface MyActivityParams {
19+
status?: string;
20+
page?: number;
21+
limit?: number;
22+
}
23+
24+
/** Unwrap the `{ success, data }` envelope (some routes return the payload bare). */
25+
function unwrap<T>(res: { data: unknown }): T {
26+
const body = res.data as { data?: T };
27+
return (
28+
body && typeof body === 'object' && 'data' in body
29+
? body.data
30+
: (res.data as T)
31+
) as T;
32+
}
33+
34+
/** The legacy `api` RequestConfig has no `params`, so encode the query inline. */
35+
function withQuery(path: string, params: MyActivityParams): string {
36+
const q = new URLSearchParams();
37+
if (params.status) q.set('status', params.status);
38+
if (params.page != null) q.set('page', String(params.page));
39+
if (params.limit != null) q.set('limit', String(params.limit));
40+
const s = q.toString();
41+
return s ? `${path}?${s}` : path;
42+
}
43+
44+
export const listMyBountyApplications = async (
45+
params: MyActivityParams = {}
46+
): Promise<BountyActivityPage<MyBountyApplicationRow>> =>
47+
unwrap(await api.get(withQuery('/bounties/me/applications', params)));
48+
49+
export const listMyBountySubmissions = async (
50+
params: MyActivityParams = {}
51+
): Promise<BountyActivityPage<MyBountySubmissionRow>> =>
52+
unwrap(await api.get(withQuery('/bounties/me/submissions', params)));
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
5+
import { bountyKeys } from './keys';
6+
import {
7+
listMyBountyApplications,
8+
listMyBountySubmissions,
9+
type MyActivityParams,
10+
} from './participant-dashboard-client';
11+
12+
/**
13+
* Builder "my bounties" activity reads (boundless-nestjs #332). Until that
14+
* backend lands the requests 404; React Query surfaces the error and the UI
15+
* shows an empty/coming-soon state. `retry: false` keeps a missing endpoint
16+
* from spamming retries.
17+
*/
18+
export function useMyBountyApplications(params: MyActivityParams = {}) {
19+
return useQuery({
20+
queryKey: [...bountyKeys.myActivity(), 'applications', params],
21+
queryFn: () => listMyBountyApplications(params),
22+
retry: false,
23+
});
24+
}
25+
26+
export function useMyBountySubmissions(params: MyActivityParams = {}) {
27+
return useQuery({
28+
queryKey: [...bountyKeys.myActivity(), 'submissions', params],
29+
queryFn: () => listMyBountySubmissions(params),
30+
retry: false,
31+
});
32+
}

features/bounties/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ export {
130130
submitSignedParticipantBounty,
131131
} from './api/participant-escrow-client';
132132

133+
// Builder "my bounties" dashboard (#332 reads; hand-typed until codegen).
134+
export type {
135+
BountyActivitySummary,
136+
MyBountyApplicationRow,
137+
MyBountySubmissionRow,
138+
BountyActivityPage,
139+
} from './types';
140+
export {
141+
listMyBountyApplications,
142+
listMyBountySubmissions,
143+
} from './api/participant-dashboard-client';
144+
export type { MyActivityParams } from './api/participant-dashboard-client';
145+
export {
146+
useMyBountyApplications,
147+
useMyBountySubmissions,
148+
} from './api/use-participant-dashboard';
149+
133150
// Participant hooks (React Query).
134151
export {
135152
useBountiesList,

0 commit comments

Comments
 (0)