Skip to content

Commit 13e09a6

Browse files
committed
Merge branch 'feat/t-replace' of github.com:boundlessfi/boundless into feat/t-replace
2 parents 2ca0987 + 1a8b26c commit 13e09a6

21 files changed

Lines changed: 1026 additions & 168 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Metadata } from 'next';
2+
3+
import { generatePageMetadata } from '@/lib/metadata';
4+
import BountySubmitGate from '@/components/bounties/detail/submit/BountySubmitGate';
5+
6+
export const metadata: Metadata = generatePageMetadata('bounties');
7+
8+
interface BountySubmitPageProps {
9+
params: Promise<{ id: string }>;
10+
}
11+
12+
export default async function BountySubmitPageRoute({
13+
params,
14+
}: BountySubmitPageProps) {
15+
const { id } = await params;
16+
17+
return (
18+
<div className='relative mx-auto min-h-screen max-w-[1440px] px-5 py-8 md:px-[50px] lg:px-[100px]'>
19+
<BountySubmitGate id={id} />
20+
</div>
21+
);
22+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { Clock } from 'lucide-react';
5+
6+
/** Format remaining time to a deadline as "Due in 4d:21h:22m" (null once past). */
7+
export function formatCountdown(
8+
deadlineIso: string,
9+
now: number
10+
): string | null {
11+
const diff = new Date(deadlineIso).getTime() - now;
12+
if (diff <= 0) return null;
13+
const d = Math.floor(diff / 86_400_000);
14+
const h = Math.floor((diff % 86_400_000) / 3_600_000);
15+
const m = Math.floor((diff % 3_600_000) / 60_000);
16+
return `Due in ${d}d:${h}h:${m}m`;
17+
}
18+
19+
/** Live-ticking due-date countdown (minute granularity). */
20+
export function DueCountdown({
21+
deadline,
22+
className = 'flex items-center gap-1.5 text-xs text-zinc-500',
23+
}: {
24+
deadline: string;
25+
className?: string;
26+
}) {
27+
const [now, setNow] = useState(() => Date.now());
28+
useEffect(() => {
29+
const id = setInterval(() => setNow(Date.now()), 30_000);
30+
return () => clearInterval(id);
31+
}, []);
32+
const label = formatCountdown(deadline, now);
33+
return (
34+
<span className={className}>
35+
<Clock className='h-3.5 w-3.5' />
36+
{label ?? 'Due date passed'}
37+
</span>
38+
);
39+
}

components/bounties/detail/BountyDetail.tsx

Lines changed: 105 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,33 @@
33
import React from 'react';
44
import dynamic from 'next/dynamic';
55
import Link from 'next/link';
6-
import {
7-
ArrowLeft,
8-
Award,
9-
Calendar,
10-
Loader2,
11-
Target,
12-
Users,
13-
} from 'lucide-react';
6+
import Image from 'next/image';
7+
import { ArrowLeft, Award, Calendar, Info, Loader2, Users } from 'lucide-react';
148

9+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1510
import { Badge } from '@/components/ui/badge';
11+
import {
12+
Tooltip,
13+
TooltipContent,
14+
TooltipTrigger,
15+
} from '@/components/ui/tooltip';
1616
import EmptyState from '@/components/EmptyState';
17-
import { computeBountyModeLabel } from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
18-
import { useBounty, useMyBountyApplication } from '@/features/bounties';
17+
import {
18+
computeBountyModeLabel,
19+
computeBountyModeDescription,
20+
} from '@/components/organization/bounties/new/tabs/schemas/modeSchema';
21+
import {
22+
CATEGORY_LABELS,
23+
type BountyCategory,
24+
} from '@/components/organization/bounties/new/tabs/schemas/scopeSchema';
25+
import {
26+
useBounty,
27+
useMyBountyApplication,
28+
useMyBountySubmission,
29+
} from '@/features/bounties';
1930
import { BountyEntryCta } from './BountyEntryCta';
2031
import BountySubmitPanel from './submit/BountySubmitPanel';
32+
import { DueCountdown } from '../DueCountdown';
2133

2234
// Markdown renderer (matches the wizard's editor package).
2335
const Markdown = dynamic(
@@ -42,6 +54,10 @@ const ordinal = (n: number): string => {
4254
export default function BountyDetail({ id }: { id: string }) {
4355
const { data: bounty, isLoading, error } = useBounty(id);
4456
const { data: myApplication } = useMyBountyApplication(id);
57+
const { data: mySubmission } = useMyBountySubmission(id);
58+
// An anchored (non-withdrawn) submission must be withdrawn before the
59+
// application/claim can be withdrawn (the contract enforces this order).
60+
const hasActiveSubmission = mySubmission?.escrowAnchorStatus === 'active';
4561

4662
if (isLoading) {
4763
return (
@@ -75,7 +91,15 @@ export default function BountyDetail({ id }: { id: string }) {
7591
bounty.entryType && bounty.claimType
7692
? computeBountyModeLabel(bounty.entryType, bounty.claimType)
7793
: 'Bounty';
94+
const modeDescription =
95+
bounty.entryType && bounty.claimType
96+
? computeBountyModeDescription(bounty.entryType, bounty.claimType)
97+
: null;
7898
const currency = bounty.rewardCurrency;
99+
const isUsdc = currency?.toUpperCase() === 'USDC';
100+
const categoryLabel = bounty.category
101+
? (CATEGORY_LABELS[bounty.category as BountyCategory] ?? bounty.category)
102+
: null;
79103

80104
return (
81105
<div>
@@ -91,26 +115,61 @@ export default function BountyDetail({ id }: { id: string }) {
91115
{/* Main */}
92116
<div className='min-w-0'>
93117
<div className='mb-4 flex flex-wrap items-center gap-2'>
94-
<Badge
95-
variant='outline'
96-
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-200'
97-
>
98-
{modeLabel}
99-
</Badge>
118+
{modeDescription ? (
119+
<Tooltip>
120+
<TooltipTrigger asChild>
121+
<Badge
122+
variant='outline'
123+
className='flex cursor-help items-center gap-1 rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-200'
124+
>
125+
{modeLabel}
126+
<Info className='h-3 w-3 text-zinc-400' />
127+
</Badge>
128+
</TooltipTrigger>
129+
<TooltipContent className='max-w-xs text-xs leading-relaxed'>
130+
{modeDescription}
131+
</TooltipContent>
132+
</Tooltip>
133+
) : (
134+
<Badge
135+
variant='outline'
136+
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-200'
137+
>
138+
{modeLabel}
139+
</Badge>
140+
)}
100141
<Badge
101142
variant='outline'
102143
className='rounded-full border-zinc-700 bg-zinc-800/60 px-3 py-1 text-xs font-medium text-zinc-300 capitalize'
103144
>
104145
{bounty.status.replace(/_/g, ' ')}
105146
</Badge>
147+
{categoryLabel && (
148+
<Badge
149+
variant='outline'
150+
className='rounded-full border-zinc-700 bg-zinc-800/40 px-3 py-1 text-xs font-medium text-zinc-300'
151+
>
152+
{categoryLabel}
153+
</Badge>
154+
)}
106155
</div>
107156

108157
<h1 className='text-2xl font-bold tracking-tight text-white sm:text-3xl'>
109158
{bounty.title}
110159
</h1>
111-
<p className='mt-2 text-sm text-zinc-400'>
112-
by <span className='text-zinc-200'>{bounty.organization.name}</span>
113-
</p>
160+
<div className='mt-2 flex items-center gap-2 text-sm text-zinc-400'>
161+
<span>by</span>
162+
<Avatar className='h-5 w-5'>
163+
<AvatarImage
164+
src={bounty.organization.logo ?? undefined}
165+
alt={bounty.organization.name}
166+
/>
167+
<AvatarFallback className='text-[10px]'>
168+
{bounty.organization.name.charAt(0).toUpperCase()}
169+
</AvatarFallback>
170+
</Avatar>
171+
<span className='text-zinc-200'>{bounty.organization.name}</span>
172+
</div>
114173

115174
{/* Description (markdown) */}
116175
<div className='boundless-markdown mt-8' data-color-mode='dark'>
@@ -154,9 +213,20 @@ export default function BountyDetail({ id }: { id: string }) {
154213
{/* Reward */}
155214
<div className='rounded-2xl border border-zinc-800 bg-zinc-900/40 p-5'>
156215
<div className='flex items-center gap-3'>
157-
<div className='bg-primary/10 flex h-11 w-11 items-center justify-center rounded-xl'>
158-
<Target className='text-primary h-5 w-5' />
159-
</div>
216+
{isUsdc ? (
217+
<Image
218+
src='/assets/usdc.svg'
219+
alt='USDC'
220+
width={44}
221+
height={44}
222+
unoptimized
223+
className='h-11 w-11'
224+
/>
225+
) : (
226+
<div className='bg-primary/10 flex h-11 w-11 items-center justify-center rounded-xl'>
227+
<Award className='text-primary h-5 w-5' />
228+
</div>
229+
)}
160230
<div>
161231
<p className='text-primary text-xl font-bold'>
162232
{bounty.rewardAmount > 0
@@ -169,6 +239,17 @@ export default function BountyDetail({ id }: { id: string }) {
169239

170240
{/* Eligibility / mode facts */}
171241
<dl className='mt-4 space-y-2 border-t border-zinc-800 pt-4 text-sm'>
242+
{bounty.submissionDeadline && (
243+
<div className='flex items-center justify-between gap-3'>
244+
<dt className='text-zinc-400'>Deadline</dt>
245+
<dd className='text-right'>
246+
<DueCountdown
247+
deadline={bounty.submissionDeadline}
248+
className='flex items-center gap-1.5 text-xs font-medium text-zinc-200'
249+
/>
250+
</dd>
251+
</div>
252+
)}
172253
{bounty.entryType === 'OPEN' &&
173254
bounty.claimType === 'SINGLE_CLAIM' &&
174255
bounty.reputationMinimum != null && (
@@ -204,12 +285,14 @@ export default function BountyDetail({ id }: { id: string }) {
204285
<BountyEntryCta
205286
bounty={bounty}
206287
myApplication={myApplication ?? null}
288+
hasActiveSubmission={hasActiveSubmission}
207289
/>
208290

209291
{/* Submit work (shown when the builder is eligible) */}
210292
<BountySubmitPanel
211293
bounty={bounty}
212294
myApplication={myApplication ?? null}
295+
hasActiveSubmission={hasActiveSubmission}
213296
/>
214297
</aside>
215298
</div>

components/bounties/detail/BountyEntryCta.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client';
22

33
import { useEffect, useState } from 'react';
4+
import Link from 'next/link';
45
import { toast } from 'sonner';
5-
import { CheckCircle2, Loader2, Lock } from 'lucide-react';
6+
import { CheckCircle2, ChevronRight, Loader2, Lock } from 'lucide-react';
67

78
import { BoundlessButton } from '@/components/buttons';
9+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
810
import { Badge } from '@/components/ui/badge';
911
import { useWalletContext } from '@/components/providers/wallet-provider';
1012
import {
@@ -41,9 +43,12 @@ const STATUS_CLASS: Record<string, string> = {
4143
export function BountyEntryCta({
4244
bounty,
4345
myApplication,
46+
hasActiveSubmission = false,
4447
}: {
4548
bounty: BountyPublic;
4649
myApplication: MyBountyApplication | null;
50+
/** An anchored submission blocks application/claim withdrawal until pulled. */
51+
hasActiveSubmission?: boolean;
4752
}) {
4853
const { walletAddress } = useWalletContext();
4954
const [dialogOpen, setDialogOpen] = useState(false);
@@ -76,6 +81,10 @@ export function BountyEntryCta({
7681
(myApplication.applicationStatus === 'WITHDRAWN' ||
7782
myApplication.status === 'withdrawn');
7883
const isActive = !!myApplication && !withdrawn;
84+
// A single-claim bounty taken by someone else. The claimant themselves sees
85+
// the "you're in this bounty" branch, so this is the locked view for others.
86+
const claimant = bounty.claimedBy ?? null;
87+
const claimedByOther = !isActive && !!claimant;
7988
// Application records are only mutable while SUBMITTED.
8089
const editable =
8190
isApplication && myApplication?.applicationStatus === 'SUBMITTED';
@@ -175,6 +184,20 @@ export function BountyEntryCta({
175184
)}
176185
</BoundlessButton>
177186
</div>
187+
) : hasActiveSubmission ? (
188+
<div className='space-y-1.5'>
189+
<BoundlessButton
190+
variant='outline'
191+
className='w-full text-zinc-500'
192+
disabled
193+
>
194+
{withdrawLabel}
195+
</BoundlessButton>
196+
<p className='flex items-center gap-1.5 text-xs text-zinc-500'>
197+
<Lock className='h-3.5 w-3.5' />
198+
Withdraw your submission first.
199+
</p>
200+
</div>
178201
) : (
179202
<BoundlessButton
180203
variant='outline'
@@ -185,6 +208,35 @@ export function BountyEntryCta({
185208
</BoundlessButton>
186209
))}
187210
</div>
211+
) : claimedByOther ? (
212+
<div className='space-y-3'>
213+
<div className='flex items-center gap-2'>
214+
<Lock className='h-4 w-4 text-zinc-400' />
215+
<span className='text-sm font-medium text-white'>Claimed</span>
216+
</div>
217+
<Link
218+
href={`/profile/${claimant!.username}`}
219+
target='_blank'
220+
className='group flex items-center gap-3 rounded-xl border border-zinc-800 bg-zinc-900/60 p-3 transition-colors hover:border-zinc-700 hover:bg-zinc-800/60'
221+
>
222+
<Avatar className='h-9 w-9'>
223+
<AvatarImage
224+
src={claimant!.avatarUrl ?? undefined}
225+
alt={claimant!.username}
226+
/>
227+
<AvatarFallback className='text-xs'>
228+
{claimant!.username.charAt(0).toUpperCase()}
229+
</AvatarFallback>
230+
</Avatar>
231+
<div className='min-w-0 flex-1'>
232+
<p className='truncate text-sm font-medium text-white group-hover:underline'>
233+
@{claimant!.username}
234+
</p>
235+
<p className='text-xs text-zinc-500'>View profile</p>
236+
</div>
237+
<ChevronRight className='h-4 w-4 shrink-0 text-zinc-500 transition-transform group-hover:translate-x-0.5 group-hover:text-zinc-300' />
238+
</Link>
239+
</div>
188240
) : (
189241
<>
190242
<BoundlessButton

0 commit comments

Comments
 (0)