Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function RewardsPage() {
refetchHackathon,
resultsPublished,
hackathon,
trackWinners,
} = useHackathonRewards(organizationId, hackathonId);

const { handleRankChange } = useRankAssignment();
Expand All @@ -43,9 +44,17 @@ export default function RewardsPage() {
refetch: refetchDistributionStatus,
} = useRewardDistributionStatus(organizationId, hackathonId);

const maxRank = useMemo(() => prizeTiers.length, [prizeTiers.length]);
// `maxRank` is the number of OVERALL prize tier slots; track tiers
// are not rank-numbered so they don't contribute to this cap. The
// rank-based rendering (podium, rank-keyed lookups) still uses
// `maxRank`. Track winners flow through `isTrackWinner` instead.
const maxRank = useMemo(
() => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length,
[prizeTiers]
);
const winners = useMemo(
() => submissions.filter(s => s.rank && s.rank <= maxRank),
() =>
submissions.filter(s => (s.rank && s.rank <= maxRank) || s.isTrackWinner),
[submissions, maxRank]
);
const hasWinners = winners.length > 0;
Expand Down Expand Up @@ -121,6 +130,7 @@ export default function RewardsPage() {
onRefreshDistributionStatus={refetchDistributionStatus}
resultsPublished={resultsPublished}
escrowAddress={hackathon?.escrowAddress || hackathon?.contractId}
trackWinners={trackWinners}
/>
)}

Expand Down
3 changes: 3 additions & 0 deletions components/organization/hackathons/rewards/PreviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ interface PreviewStepProps {
rank: number;
prizeAmount: string;
currency: string;
place?: string;
kind?: 'OVERALL' | 'TRACK';
trackId?: string;
}>;
announcement: string;
onEditAnnouncement: () => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,25 @@ export default function PublishWinnersWizard({
hackathonId,
onSuccess,
}: PublishWinnersWizardProps) {
const maxRank = prizeTiers.length;
// `maxRank` only counts OVERALL slots — track tiers don't have
// numeric ranks. The previous code used `prizeTiers.length`, which
// over-counted by the number of track tiers and could let a phantom
// overall rank slip through.
const maxRank = useMemo(
() => prizeTiers.filter(t => !t.kind || t.kind === 'OVERALL').length,
[prizeTiers]
);

// Include both overall winners (rank-keyed) AND track winners
// (flagged by `isTrackWinner` via useHackathonRewards). The BE
// trigger endpoint resolves the actual payout list itself; this
// array only drives the preview UI.
const winners = useMemo(
() =>
submissions.filter(
s => s.rank !== undefined && s.rank !== null && s.rank <= maxRank
s =>
(s.rank !== undefined && s.rank !== null && s.rank <= maxRank) ||
s.isTrackWinner
),
[submissions, maxRank]
);
Expand Down Expand Up @@ -92,12 +105,21 @@ export default function PublishWinnersWizard({
rank: tier.rank,
prizeAmount: tier.prizeAmount,
currency: tier.currency,
place: tier.place,
kind: tier.kind,
trackId: tier.trackId,
})),
[prizeTiers]
);

// Format the prize for a given rank-based tier slot (overall). Track
// winners look up their prize via tier.trackId in WinnersGrid
// instead; this helper stays focused on overall placements so the
// preview's existing flow doesn't get gnarlier than needed.
const getPrizeForRank = (rank: number) => {
const tier = mappedPrizeTiers.find(t => t.rank === rank);
const tier = mappedPrizeTiers.find(
t => (!t.kind || t.kind === 'OVERALL') && t.rank === rank
);
if (tier) {
const amount = parseFloat(tier.prizeAmount || '0').toLocaleString(
'en-US'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import {
} from 'lucide-react';
import { BoundlessButton } from '@/components/buttons';
import PodiumSection from '@/components/organization/hackathons/rewards/PodiumSection';
import { TrackWinnersSection } from '@/components/organization/hackathons/rewards/TrackWinnersSection';
import SubmissionsList from '@/components/organization/hackathons/rewards/SubmissionsList';
import EscrowStatusCard from '@/components/organization/hackathons/rewards/EscrowStatusCard';
import { RewardDistributionStatusBanner } from '@/components/organization/hackathons/rewards/RewardDistributionStatusBanner';
import BoundlessSheet from '@/components/sheet/boundless-sheet';
import { Submission } from '@/components/organization/hackathons/rewards/types';
import type {
HackathonEscrowData,
HackathonTrackWinner,
RewardDistributionStatusResponse,
RewardDistributionStatusEnum,
} from '@/lib/api/hackathons';
Expand All @@ -41,6 +43,12 @@ interface RewardsPageContentProps {
onRefreshDistributionStatus?: () => void;
resultsPublished?: boolean;
escrowAddress?: string;
/**
* Per-track winners stamped by publishResults. Rendered in a
* dedicated section below the rank-based podium. Empty pre-publish
* and on OVERALL_ONLY hackathons.
*/
trackWinners?: HackathonTrackWinner[];
}

export const RewardsPageContent: React.FC<RewardsPageContentProps> = ({
Expand All @@ -57,6 +65,7 @@ export const RewardsPageContent: React.FC<RewardsPageContentProps> = ({
onRefreshDistributionStatus,
resultsPublished,
escrowAddress,
trackWinners = [],
}) => {
const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false);

Expand Down Expand Up @@ -214,6 +223,7 @@ export const RewardsPageContent: React.FC<RewardsPageContentProps> = ({
</div>
<div className='space-y-6'>
<PodiumSection submissions={submissions} maxRank={maxRank} />
<TrackWinnersSection trackWinners={trackWinners} />
<div>
<h3 className='mb-4 text-lg font-medium text-white'>
All Submissions
Expand Down
114 changes: 114 additions & 0 deletions components/organization/hackathons/rewards/TrackWinnersSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client';

import React from 'react';
import { Layers, Trophy } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HackathonTrackWinner } from '@/lib/api/hackathons';

interface TrackWinnersSectionProps {
trackWinners: HackathonTrackWinner[];
}

/**
* Renders track winners on the organizer rewards page.
*
* Mirrors the pattern used by the public WinnersTab: one card per
* track, scoped to its winning submission. Sits below the rank-based
* PodiumSection so the page reads as "overall winners on top, track
* winners below." Hidden entirely on OVERALL_ONLY hackathons (empty
* trackWinners → render null).
*/
export const TrackWinnersSection: React.FC<TrackWinnersSectionProps> = ({
trackWinners,
}) => {
if (!trackWinners || trackWinners.length === 0) return null;

return (
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<Layers className='text-primary h-5 w-5' />
<h3 className='text-lg font-semibold text-white'>Track Winners</h3>
<span className='text-xs text-gray-500'>
({trackWinners.length} track
{trackWinners.length === 1 ? '' : 's'})
</span>
</div>

<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
{trackWinners.map(trackWinner => (
<TrackWinnerCard
key={`${trackWinner.submissionId}-${trackWinner.track.id}`}
trackWinner={trackWinner}
/>
))}
</div>
</div>
);
};

const formatPrize = (raw: string | null | undefined): string | null => {
if (!raw) return null;
// Backend emits prize like "100 USDC" or "$100"; normalise to
// "<amount> USDC" so the chip stays consistent across hackathons.
const match = raw.match(/^(?:USDC)?\s*\$?(\d+(?:[.,]\d+)?)\s*(?:USDC)?$/i);
return match ? `${match[1]} USDC` : raw;
};

const TrackWinnerCard: React.FC<{ trackWinner: HackathonTrackWinner }> = ({
trackWinner,
}) => {
const prize = formatPrize(trackWinner.prize);

return (
<div className='bg-background-card relative w-full overflow-hidden rounded-xl border border-white/5 p-4 transition-colors duration-200 hover:border-white/10'>
<div className='mb-3 flex flex-wrap items-center justify-between gap-2'>
<Badge variant='outline' className='text-primary border-primary/40'>
{trackWinner.track.name}
</Badge>
{prize && (
<div className='flex items-center gap-1.5 rounded-full border border-[#2775CA]/20 bg-[#2775CA]/10 px-2.5 py-1'>
<Trophy className='h-3.5 w-3.5 text-yellow-500' />
<span className='text-[10px] font-bold text-white uppercase'>
{prize}
</span>
</div>
)}
</div>

<div className='mb-3 flex items-center gap-3'>
<div className='flex -space-x-2'>
{trackWinner.participants.slice(0, 3).map((p, i) => (
<Avatar
key={`${p.username || 'anon'}-${i}`}
className={cn('border-background-card h-10 w-10 border-2 shadow')}
>
<AvatarImage src={p.avatar} alt={p.username || 'Participant'} />
<AvatarFallback className='bg-gray-800 text-xs text-white uppercase'>
{p.username?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
))}
{trackWinner.participants.length > 3 && (
<div className='border-background-card flex h-10 w-10 items-center justify-center rounded-full border-2 bg-gray-800 text-xs font-medium text-gray-300'>
+{trackWinner.participants.length - 3}
</div>
)}
</div>
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-semibold text-white'>
{trackWinner.projectName}
</div>
{trackWinner.teamName && (
<div className='truncate text-xs text-gray-400'>
{trackWinner.teamName}
</div>
)}
</div>
</div>
</div>
);
};

export default TrackWinnersSection;
Loading