Skip to content

Commit d398f6d

Browse files
committed
feat(bounty): wrap flow - results, announcement, archive (#635)
Add the post-payout wrap surface for the operate dashboard: - Results tab (completed bounties): winners by tier + payout tx, plus a publish-winner-announcement form that fans out notifications. - Public results block on the bounty detail: announcement + winners. - Archive / restore for completed/cancelled bounties in Settings (soft close, never a hard delete). Wires the boundless-nestjs wrap endpoints (publish-results, archive, restore, public results/announcement) through a typed client + hooks.
1 parent 68be515 commit d398f6d

9 files changed

Lines changed: 638 additions & 0 deletions

File tree

components/bounties/detail/BountyDetail.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { ordinal } from '@/lib/utils';
3131
import { BountyEntryCta } from './BountyEntryCta';
3232
import BountySubmitPanel from './submit/BountySubmitPanel';
33+
import { BountyResults } from './BountyResults';
3334
import { DueCountdown } from '../DueCountdown';
3435

3536
// Markdown renderer (matches the wizard's editor package).
@@ -201,6 +202,11 @@ export default function BountyDetail({ id }: { id: string }) {
201202
</div>
202203
</div>
203204
)}
205+
206+
{/* Results (winners + announcement) once the bounty is completed. */}
207+
{bounty.status === 'completed' && (
208+
<BountyResults bountyId={bounty.id} currency={currency} />
209+
)}
204210
</div>
205211

206212
{/* Sidebar */}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use client';
2+
3+
import { ExternalLink, Megaphone, Trophy } from 'lucide-react';
4+
5+
import {
6+
useBountyAnnouncement,
7+
useBountyResults,
8+
type BountyResultsWinner,
9+
} from '@/features/bounties';
10+
import { formatPublicKey, ordinal } from '@/lib/utils';
11+
import { getTransactionExplorerUrl } from '@/lib/wallet-utils';
12+
13+
function formatAmount(amount: string | number): string {
14+
const n = Number(amount);
15+
return Number.isFinite(n)
16+
? n.toLocaleString(undefined, { maximumFractionDigits: 7 })
17+
: String(amount);
18+
}
19+
20+
/**
21+
* Public results block for a completed bounty: the winner announcement (if the
22+
* organizer published one) and winners by tier with their payout transaction.
23+
* Renders nothing until there is something to show.
24+
*/
25+
export function BountyResults({
26+
bountyId,
27+
currency,
28+
}: {
29+
bountyId: string;
30+
currency: string;
31+
}) {
32+
const { data: results } = useBountyResults(bountyId);
33+
const { data: announcement } = useBountyAnnouncement(bountyId);
34+
35+
const winners = (results?.winners ?? [])
36+
.slice()
37+
.sort((a, b) => a.tierPosition - b.tierPosition);
38+
39+
if (winners.length === 0 && !announcement) return null;
40+
41+
return (
42+
<div className='mt-10'>
43+
<h2 className='mb-3 flex items-center gap-2 text-lg font-semibold text-white'>
44+
<Trophy className='text-primary h-5 w-5' />
45+
Results
46+
</h2>
47+
48+
{announcement && (
49+
<div className='mb-4 rounded-2xl border border-zinc-800 bg-zinc-900/40 p-4'>
50+
<div className='mb-1.5 flex items-center gap-2 text-sm font-medium text-white'>
51+
<Megaphone className='h-4 w-4 text-zinc-400' />
52+
Announcement
53+
</div>
54+
<p className='text-sm whitespace-pre-wrap text-zinc-300'>
55+
{announcement.message}
56+
</p>
57+
</div>
58+
)}
59+
60+
{winners.length > 0 && (
61+
<div className='space-y-2'>
62+
{winners.map(w => (
63+
<WinnerRow key={w.submissionId} winner={w} currency={currency} />
64+
))}
65+
</div>
66+
)}
67+
</div>
68+
);
69+
}
70+
71+
function WinnerRow({
72+
winner,
73+
currency,
74+
}: {
75+
winner: BountyResultsWinner;
76+
currency: string;
77+
}) {
78+
return (
79+
<div className='flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900/30 px-4 py-3'>
80+
<div className='min-w-0'>
81+
<p className='text-sm font-medium text-zinc-300'>
82+
{ordinal(winner.tierPosition)} place
83+
</p>
84+
{winner.applicantAddress && (
85+
<p className='font-mono text-xs text-zinc-500'>
86+
{formatPublicKey(winner.applicantAddress)}
87+
</p>
88+
)}
89+
</div>
90+
<div className='text-right'>
91+
<p className='text-primary text-sm font-semibold'>
92+
{formatAmount(winner.tierAmount)} {currency}
93+
</p>
94+
{winner.rewardTransactionHash && (
95+
<a
96+
href={getTransactionExplorerUrl(winner.rewardTransactionHash)}
97+
target='_blank'
98+
rel='noreferrer'
99+
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
100+
>
101+
Payout tx
102+
<ExternalLink className='h-3 w-3' />
103+
</a>
104+
)}
105+
</div>
106+
</div>
107+
);
108+
}

components/organization/bounties/manage/BountyManagementDashboard.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import BountySubmissionsPanel from './BountySubmissionsPanel';
2828
import BountyPayoutPanel from './BountyPayoutPanel';
2929
import BountyApplicationsPanel from './BountyApplicationsPanel';
3030
import BountySettingsPanel from './BountySettingsPanel';
31+
import BountyResultsPanel from './BountyResultsPanel';
3132

3233
export default function BountyManagementDashboard() {
3334
const params = useParams<{ id: string; bountyId: string }>();
@@ -85,6 +86,7 @@ export default function BountyManagementDashboard() {
8586
const isApplication =
8687
overview.entryType === 'APPLICATION_LIGHT' ||
8788
overview.entryType === 'APPLICATION_FULL';
89+
const isCompleted = overview.status === 'completed';
8890
const modeLabel =
8991
overview.entryType && overview.claimType
9092
? computeBountyModeLabel(overview.entryType, overview.claimType)
@@ -151,6 +153,7 @@ export default function BountyManagementDashboard() {
151153
)}
152154
<TabsTrigger value='submissions'>Submissions</TabsTrigger>
153155
<TabsTrigger value='payout'>Payout &amp; Winners</TabsTrigger>
156+
{isCompleted && <TabsTrigger value='results'>Results</TabsTrigger>}
154157
<TabsTrigger value='settings'>Settings</TabsTrigger>
155158
</TabsList>
156159

@@ -183,6 +186,15 @@ export default function BountyManagementDashboard() {
183186
staged={stagedWinners}
184187
/>
185188
</TabsContent>
189+
{isCompleted && (
190+
<TabsContent value='results'>
191+
<BountyResultsPanel
192+
organizationId={organizationId}
193+
bountyId={bountyId}
194+
overview={overview}
195+
/>
196+
</TabsContent>
197+
)}
186198
<TabsContent value='settings'>
187199
<BountySettingsPanel
188200
organizationId={organizationId}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { toast } from 'sonner';
5+
import { CheckCircle2, ExternalLink, Loader2, Megaphone } from 'lucide-react';
6+
7+
import { Textarea } from '@/components/ui/textarea';
8+
import { BoundlessButton } from '@/components/buttons';
9+
import EmptyState from '@/components/EmptyState';
10+
import {
11+
useBountyAnnouncement,
12+
useBountyResults,
13+
usePublishBountyResults,
14+
type BountyOperateOverview,
15+
type BountyResultsWinner,
16+
} from '@/features/bounties';
17+
import { formatPublicKey, ordinal } from '@/lib/utils';
18+
import { getTransactionExplorerUrl } from '@/lib/wallet-utils';
19+
20+
/** Token amounts arrive as decimal strings; show full Stellar precision. */
21+
function formatAmount(amount: string | number): string {
22+
const n = Number(amount);
23+
return Number.isFinite(n)
24+
? n.toLocaleString(undefined, { maximumFractionDigits: 7 })
25+
: String(amount);
26+
}
27+
28+
const MAX_MESSAGE = 2000;
29+
30+
export default function BountyResultsPanel({
31+
organizationId,
32+
bountyId,
33+
overview,
34+
}: {
35+
organizationId: string;
36+
bountyId: string;
37+
overview: BountyOperateOverview;
38+
}) {
39+
const isCompleted = overview.status === 'completed';
40+
41+
const { data: results, isLoading: resultsLoading } = useBountyResults(
42+
bountyId,
43+
{ enabled: isCompleted }
44+
);
45+
const { data: announcement, isLoading: announcementLoading } =
46+
useBountyAnnouncement(bountyId, { enabled: isCompleted });
47+
const publish = usePublishBountyResults(organizationId, bountyId);
48+
49+
const [message, setMessage] = useState('');
50+
51+
if (!isCompleted) {
52+
return (
53+
<EmptyState
54+
title='Results not ready'
55+
description='Publish the winner announcement once the bounty is completed and paid out.'
56+
type='compact'
57+
/>
58+
);
59+
}
60+
61+
if (resultsLoading || announcementLoading) {
62+
return (
63+
<div className='flex items-center justify-center py-16'>
64+
<Loader2 className='h-5 w-5 animate-spin text-zinc-500' />
65+
</div>
66+
);
67+
}
68+
69+
const winners = (results?.winners ?? [])
70+
.slice()
71+
.sort((a, b) => a.tierPosition - b.tierPosition);
72+
73+
const onPublish = () => {
74+
const trimmed = message.trim();
75+
if (!trimmed) {
76+
toast.error('Write an announcement message first.');
77+
return;
78+
}
79+
publish.mutate(
80+
{ message: trimmed },
81+
{
82+
onSuccess: () => {
83+
toast.success('Winners announced.');
84+
setMessage('');
85+
},
86+
onError: err =>
87+
toast.error(
88+
err instanceof Error ? err.message : 'Failed to publish results'
89+
),
90+
}
91+
);
92+
};
93+
94+
return (
95+
<div className='space-y-6'>
96+
{/* Winners */}
97+
<section className='space-y-3'>
98+
<h3 className='text-sm font-semibold text-white'>Winners</h3>
99+
{winners.length === 0 ? (
100+
<EmptyState
101+
title='No winners recorded'
102+
description='This bounty completed without recorded winners.'
103+
type='compact'
104+
/>
105+
) : (
106+
<div className='space-y-2'>
107+
{winners.map(w => (
108+
<WinnerRow
109+
key={w.submissionId}
110+
winner={w}
111+
currency={results?.rewardCurrency ?? overview.rewardCurrency}
112+
/>
113+
))}
114+
</div>
115+
)}
116+
</section>
117+
118+
{/* Announcement */}
119+
<section className='space-y-3 border-t border-zinc-800 pt-6'>
120+
<div className='flex items-center gap-2'>
121+
<Megaphone className='h-4 w-4 text-zinc-400' />
122+
<h3 className='text-sm font-semibold text-white'>
123+
Winner announcement
124+
</h3>
125+
</div>
126+
127+
{announcement ? (
128+
<div className='space-y-2 rounded-2xl border border-emerald-500/30 bg-emerald-500/[0.06] p-4'>
129+
<div className='flex items-center gap-2 text-sm text-white'>
130+
<CheckCircle2 className='h-4 w-4 text-emerald-400' />
131+
Published
132+
</div>
133+
<p className='text-sm whitespace-pre-wrap text-zinc-300'>
134+
{announcement.message}
135+
</p>
136+
</div>
137+
) : (
138+
<div className='space-y-3'>
139+
<p className='text-sm text-zinc-400'>
140+
Publish a message to announce the winners. It notifies the winners
141+
and the community, and appears on the public results page.
142+
</p>
143+
<Textarea
144+
value={message}
145+
onChange={e => setMessage(e.target.value.slice(0, MAX_MESSAGE))}
146+
placeholder='Congratulations to our winners…'
147+
rows={5}
148+
disabled={publish.isPending}
149+
className='border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600'
150+
/>
151+
<div className='flex items-center justify-between'>
152+
<span className='text-xs text-zinc-600'>
153+
{message.length}/{MAX_MESSAGE}
154+
</span>
155+
<BoundlessButton
156+
onClick={onPublish}
157+
disabled={publish.isPending || !message.trim()}
158+
>
159+
{publish.isPending ? (
160+
<>
161+
<Loader2 className='h-4 w-4 animate-spin' />
162+
Publishing…
163+
</>
164+
) : (
165+
'Publish & announce'
166+
)}
167+
</BoundlessButton>
168+
</div>
169+
</div>
170+
)}
171+
</section>
172+
</div>
173+
);
174+
}
175+
176+
function WinnerRow({
177+
winner,
178+
currency,
179+
}: {
180+
winner: BountyResultsWinner;
181+
currency: string;
182+
}) {
183+
return (
184+
<div className='flex items-center justify-between rounded-2xl border border-zinc-800 bg-zinc-900/40 p-4'>
185+
<div className='min-w-0'>
186+
<p className='text-sm font-medium text-white'>
187+
{ordinal(winner.tierPosition)} place
188+
</p>
189+
{winner.applicantAddress && (
190+
<p className='font-mono text-xs text-zinc-500'>
191+
{formatPublicKey(winner.applicantAddress)}
192+
</p>
193+
)}
194+
</div>
195+
<div className='text-right'>
196+
<p className='text-primary text-sm font-semibold'>
197+
{formatAmount(winner.tierAmount)} {currency}
198+
</p>
199+
{winner.rewardTransactionHash && (
200+
<a
201+
href={getTransactionExplorerUrl(winner.rewardTransactionHash)}
202+
target='_blank'
203+
rel='noreferrer'
204+
className='text-primary inline-flex items-center gap-1 text-xs hover:underline'
205+
>
206+
Payout tx
207+
<ExternalLink className='h-3 w-3' />
208+
</a>
209+
)}
210+
</div>
211+
</div>
212+
);
213+
}

0 commit comments

Comments
 (0)