diff --git a/components/bounties/detail/BountyDetail.tsx b/components/bounties/detail/BountyDetail.tsx index 7a203e05..f6336afe 100644 --- a/components/bounties/detail/BountyDetail.tsx +++ b/components/bounties/detail/BountyDetail.tsx @@ -30,6 +30,7 @@ import { import { ordinal } from '@/lib/utils'; import { BountyEntryCta } from './BountyEntryCta'; import BountySubmitPanel from './submit/BountySubmitPanel'; +import { BountyResults } from './BountyResults'; import { DueCountdown } from '../DueCountdown'; // Markdown renderer (matches the wizard's editor package). @@ -201,6 +202,11 @@ export default function BountyDetail({ id }: { id: string }) { )} + + {/* Results (winners + announcement) once the bounty is completed. */} + {bounty.status === 'completed' && ( + + )} {/* Sidebar */} diff --git a/components/bounties/detail/BountyResults.tsx b/components/bounties/detail/BountyResults.tsx new file mode 100644 index 00000000..f601f9df --- /dev/null +++ b/components/bounties/detail/BountyResults.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { ExternalLink, Megaphone, Trophy } from 'lucide-react'; + +import { + useBountyAnnouncement, + useBountyResults, + type BountyResultsWinner, +} from '@/features/bounties'; +import { formatPublicKey, ordinal } from '@/lib/utils'; +import { getTransactionExplorerUrl } from '@/lib/wallet-utils'; + +function formatAmount(amount: string | number): string { + const n = Number(amount); + return Number.isFinite(n) + ? n.toLocaleString(undefined, { maximumFractionDigits: 7 }) + : String(amount); +} + +/** + * Public results block for a completed bounty: the winner announcement (if the + * organizer published one) and winners by tier with their payout transaction. + * Renders nothing until there is something to show. + */ +export function BountyResults({ + bountyId, + currency, +}: { + bountyId: string; + currency: string; +}) { + const { data: results } = useBountyResults(bountyId); + const { data: announcement } = useBountyAnnouncement(bountyId); + + const winners = (results?.winners ?? []) + .slice() + .sort((a, b) => a.tierPosition - b.tierPosition); + + if (winners.length === 0 && !announcement) return null; + + return ( +
+

+ + Results +

+ + {announcement && ( +
+
+ + Announcement +
+

+ {announcement.message} +

+
+ )} + + {winners.length > 0 && ( +
+ {winners.map(w => ( + + ))} +
+ )} +
+ ); +} + +function WinnerRow({ + winner, + currency, +}: { + winner: BountyResultsWinner; + currency: string; +}) { + return ( +
+
+

+ {ordinal(winner.tierPosition)} place +

+ {winner.applicantAddress && ( +

+ {formatPublicKey(winner.applicantAddress)} +

+ )} +
+
+

+ {formatAmount(winner.tierAmount)} {currency} +

+ {winner.rewardTransactionHash && ( + + Payout tx + + + )} +
+
+ ); +} diff --git a/components/organization/bounties/manage/BountyManagementDashboard.tsx b/components/organization/bounties/manage/BountyManagementDashboard.tsx index 448ec9f5..38ca2944 100644 --- a/components/organization/bounties/manage/BountyManagementDashboard.tsx +++ b/components/organization/bounties/manage/BountyManagementDashboard.tsx @@ -28,6 +28,7 @@ import BountySubmissionsPanel from './BountySubmissionsPanel'; import BountyPayoutPanel from './BountyPayoutPanel'; import BountyApplicationsPanel from './BountyApplicationsPanel'; import BountySettingsPanel from './BountySettingsPanel'; +import BountyResultsPanel from './BountyResultsPanel'; export default function BountyManagementDashboard() { const params = useParams<{ id: string; bountyId: string }>(); @@ -85,6 +86,7 @@ export default function BountyManagementDashboard() { const isApplication = overview.entryType === 'APPLICATION_LIGHT' || overview.entryType === 'APPLICATION_FULL'; + const isCompleted = overview.status === 'completed'; const modeLabel = overview.entryType && overview.claimType ? computeBountyModeLabel(overview.entryType, overview.claimType) @@ -151,6 +153,7 @@ export default function BountyManagementDashboard() { )} Submissions Payout & Winners + {isCompleted && Results} Settings @@ -183,6 +186,15 @@ export default function BountyManagementDashboard() { staged={stagedWinners} /> + {isCompleted && ( + + + + )} + ); + } + + if (resultsLoading || announcementLoading) { + return ( +
+ +
+ ); + } + + const winners = (results?.winners ?? []) + .slice() + .sort((a, b) => a.tierPosition - b.tierPosition); + + const onPublish = () => { + const trimmed = message.trim(); + if (!trimmed) { + toast.error('Write an announcement message first.'); + return; + } + publish.mutate( + { message: trimmed }, + { + onSuccess: () => { + toast.success('Winners announced.'); + setMessage(''); + }, + onError: err => + toast.error( + err instanceof Error ? err.message : 'Failed to publish results' + ), + } + ); + }; + + return ( +
+ {/* Winners */} +
+

Winners

+ {winners.length === 0 ? ( + + ) : ( +
+ {winners.map(w => ( + + ))} +
+ )} +
+ + {/* Announcement */} +
+
+ +

+ Winner announcement +

+
+ + {announcement ? ( +
+
+ + Published +
+

+ {announcement.message} +

+
+ ) : ( +
+

+ Publish a message to announce the winners. It notifies the winners + and the community, and appears on the public results page. +

+