Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/me/bounties/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import MyBountiesPage from '@/components/me/bounties/MyBountiesPage';

export default function MyBountiesRoutePage() {
return <MyBountiesPage />;
}
14 changes: 14 additions & 0 deletions app/me/earnings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ activity }) => (
<span className='capitalize'>{activity.source}</span>
<span>•</span>
<span>{new Date(activity.occurredAt).toLocaleDateString()}</span>
{/* Reward-receipt tx (txHash arrives with backend #335; cast until codegen). */}
{(activity as { txHash?: string }).txHash && (
<>
<span>•</span>
<a
href={`https://stellar.expert/explorer/testnet/tx/${(activity as { txHash?: string }).txHash}`}
target='_blank'
rel='noreferrer'
className='text-primary hover:underline'
>
View tx
</a>
</>
)}
</div>
</div>
<div className='text-right'>
Expand Down
9 changes: 9 additions & 0 deletions components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IconUserCircle,
IconUsers,
IconRocket,
IconTarget,
} from '@tabler/icons-react';

import { NavMain } from '@/components/nav-main';
Expand Down Expand Up @@ -67,6 +68,13 @@ const getNavigationData = (counts?: {
: undefined,
},
],
bounties: [
{
title: 'My Bounties',
url: '/me/bounties',
icon: IconTarget,
},
],
account: [
{
title: 'Profile',
Expand Down Expand Up @@ -151,6 +159,7 @@ export function AppSidebar({
<NavMain items={navigationData.main} />
<NavMain items={navigationData.crowdfunding} label='Crowdfunding' />
<NavMain items={navigationData.hackathons} label='Hackathons' />
<NavMain items={navigationData.bounties} label='Bounties' />
<NavMain items={navigationData.account} label='Account' />
</SidebarContent>
{/* Footer with User */}
Expand Down
216 changes: 216 additions & 0 deletions components/me/bounties/MyBountiesPage.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { label: string; className: string }> = {
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<Tab>('applications');
const applications = useMyBountyApplications();
const submissions = useMyBountySubmissions();

const active = tab === 'applications' ? applications : submissions;
const rows =
tab === 'applications'
? (applications.data?.items ?? [])
: (submissions.data?.items ?? []);

return (
<div className='mx-auto max-w-5xl px-4 py-8'>
<h1 className='text-2xl font-bold tracking-tight text-white'>
My bounties
</h1>
<p className='mt-1 text-sm text-zinc-400'>
Track your applications, submissions, and results.
</p>

{/* Tabs */}
<div className='mt-6 mb-4 flex gap-2'>
{(['applications', 'submissions'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-full px-4 py-2 text-sm font-medium capitalize transition-all ${
tab === t
? 'bg-primary text-black'
: 'bg-zinc-900 text-white hover:bg-zinc-800'
}`}
>
{t}
</button>
))}
</div>

{active.isLoading ? (
<div className='flex items-center justify-center py-20'>
<Loader2 className='h-6 w-6 animate-spin text-zinc-500' />
</div>
) : rows.length === 0 ? (
<div className='py-16'>
<EmptyState
title={`No ${tab} yet`}
description={
tab === 'applications'
? 'Apply to a bounty and it will show up here.'
: 'Submit your work on a bounty and it will show up here.'
}
type='compact'
/>
</div>
) : (
<div className='overflow-hidden rounded-xl border border-zinc-800'>
{tab === 'applications'
? (rows as MyBountyApplicationRow[]).map(row => (
<ApplicationRow key={row.id} row={row} />
))
: (rows as MyBountySubmissionRow[]).map(row => (
<SubmissionRow key={row.id} row={row} />
))}
</div>
)}
</div>
);
}

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 (
<Link
href={`/bounties/${row.bounty.id}`}
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'
>
<div className='min-w-0'>
<p className='truncate text-sm font-medium text-white'>
{row.bounty.title}
</p>
<p className='text-xs text-zinc-500'>
{mode(row.bounty)} · applied {fmtDate(row.createdAt)}
</p>
</div>
<Badge
variant='outline'
className={`shrink-0 rounded-full border-transparent px-3 py-1 text-xs font-medium ${status.className}`}
>
{status.label}
</Badge>
</Link>
);
}

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

return (
<div className='flex items-center justify-between gap-4 border-b border-zinc-800 px-4 py-3 last:border-0'>
<Link href={`/bounties/${row.bounty.id}`} className='min-w-0 flex-1'>
<p className='truncate text-sm font-medium text-white hover:underline'>
{row.bounty.title}
</p>
<p className='text-xs text-zinc-500'>
{mode(row.bounty)} · submitted {fmtDate(row.createdAt)}
</p>
</Link>

<div className='flex shrink-0 items-center gap-3'>
{won && (
<span className='text-primary text-sm font-semibold'>
{Number(row.tierAmount).toLocaleString()}{' '}
{row.bounty.rewardCurrency}
</span>
)}
{row.rewardTransactionHash && (
<a
href={`https://stellar.expert/explorer/testnet/tx/${row.rewardTransactionHash}`}
target='_blank'
rel='noreferrer'
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
title='View payout transaction'
>
tx
<ExternalLink className='h-3 w-3' />
</a>
)}
<Badge
variant='outline'
className={`rounded-full border-transparent px-3 py-1 text-xs font-medium ${progress.className}`}
>
{progress.label}
</Badge>
</div>
</div>
);
}
52 changes: 52 additions & 0 deletions features/bounties/api/participant-dashboard-client.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<BountyActivityPage<MyBountyApplicationRow>> =>
unwrap(await api.get(withQuery('/bounties/me/applications', params)));

export const listMyBountySubmissions = async (
params: MyActivityParams = {}
): Promise<BountyActivityPage<MyBountySubmissionRow>> =>
unwrap(await api.get(withQuery('/bounties/me/submissions', params)));
32 changes: 32 additions & 0 deletions features/bounties/api/use-participant-dashboard.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
17 changes: 17 additions & 0 deletions features/bounties/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading