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.
+
+
+ )}
+
+
+ );
+}
+
+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/BountySettingsPanel.tsx b/components/organization/bounties/manage/BountySettingsPanel.tsx
index 8d1c8c62..6cb6be9f 100644
--- a/components/organization/bounties/manage/BountySettingsPanel.tsx
+++ b/components/organization/bounties/manage/BountySettingsPanel.tsx
@@ -1,11 +1,14 @@
'use client';
import { useState } from 'react';
+import { toast } from 'sonner';
import {
AlertTriangle,
+ Archive,
CheckCircle2,
ExternalLink,
Loader2,
+ RotateCcw,
} from 'lucide-react';
import {
@@ -20,6 +23,8 @@ import { Input } from '@/components/ui/input';
import { BoundlessButton } from '@/components/buttons';
import {
ESCROW_PHASE_LABEL,
+ useArchiveBounty,
+ useRestoreBounty,
type BountyOperateOverview,
} from '@/features/bounties';
import { useBountyCancel } from '@/hooks/use-bounty-cancel';
@@ -49,12 +54,44 @@ export default function BountySettingsPanel({
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
+ const archiveMutation = useArchiveBounty(organizationId, bountyId);
+ const restoreMutation = useRestoreBounty(organizationId, bountyId);
+ // The overview read doesn't carry an archived flag, so we track the last
+ // action locally for immediate feedback; it resets to active on reload.
+ const [archived, setArchived] = useState(false);
+
const isCancelled = overview.status === 'cancelled';
const isTerminal = TERMINAL_STATUSES.has(overview.status);
// A draft has no on-chain escrow to refund; cancel only applies once funded.
const isPublished = !!overview.escrowEventId;
const running = cancelOp.isRunning;
+ const onArchive = () =>
+ archiveMutation.mutate(undefined, {
+ onSuccess: () => {
+ setArchived(true);
+ toast.success('Bounty archived.');
+ },
+ onError: err =>
+ toast.error(
+ err instanceof Error ? err.message : 'Failed to archive the bounty'
+ ),
+ });
+
+ const onRestore = () =>
+ restoreMutation.mutate(undefined, {
+ onSuccess: () => {
+ setArchived(false);
+ toast.success('Bounty restored.');
+ },
+ onError: err =>
+ toast.error(
+ err instanceof Error ? err.message : 'Failed to restore the bounty'
+ ),
+ });
+
+ const archiveBusy = archiveMutation.isPending || restoreMutation.isPending;
+
return (
@@ -119,6 +156,64 @@ export default function BountySettingsPanel({
+ {/* Archive / restore — only for closed-out bounties. Never a hard delete. */}
+ {isTerminal && (
+
+
+
+
+
+ {archived ? 'Bounty archived' : 'Archive this bounty'}
+
+
+ {archived
+ ? 'This bounty is hidden from your active list. Restore it to bring it back. Its records are never deleted.'
+ : 'Move this closed-out bounty out of your active list. It stays fully recoverable and is never deleted.'}
+
+ {archived ? (
+
+ {restoreMutation.isPending ? (
+ <>
+
+ Restoring…
+ >
+ ) : (
+ <>
+
+ Restore bounty
+ >
+ )}
+
+ ) : (
+
+ {archiveMutation.isPending ? (
+ <>
+
+ Archiving…
+ >
+ ) : (
+ <>
+
+ Archive bounty
+ >
+ )}
+
+ )}
+
+
+
+ )}
+
{/* Explicit-confirm gate before the irreversible on-chain refund. */}