Skip to content

Commit 6101e01

Browse files
0xdevcollinsclaude
andauthored
fix(rewards): polish publish-wizard preview to industry-standard layout (#580)
The wizard preview's "3/8 Winners Assigned" line plus the existing WinnerCard layout was confusing — "$300 USDC" double-signed the currency, "0 Comments" was meaningless in a payout preview, and track winners got a "4th Position" ribbon they didn't earn. Redesigned to match the patterns Devpost / Devfolio / ETHGlobal use in their publish flows: WinnersGrid header: - Replace the cryptic "X/Y Winners Assigned" with a callout chip: green check + "All N winners assigned" when complete, amber warning + "X of Y assigned (Z unassigned)" when not. - Show the total prize pool ("1,500 USDC pool") as a sibling chip so the organizer sees the dollar figure they're committing. - Render "Overall Placements" as a sub-header only when track winners also exist (avoids redundant heading on OVERALL_ONLY). WinnerCard: - Drop the dollar sign for non-dollar currencies — `"$300 USDC"` is now `"300 USDC"`, a cleaner industry-standard read. - Track winners get a track-name chip ("Best UI/UX") instead of a synthetic-rank ribbon ("4th Position"). - Drop the "0 Comments" noise; not a meaningful signal at payout time. - Drop the placeholder bitmed.png; surface project name + participant name + category as the primary content. - Cards now have consistent dimensions (no podium scaling for tracks), uniform border + hover treatment, prize chip aligned to the right. Only the publish-wizard preview is touched; the public Winners tab and the rewards podium are unchanged. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c13e9ea commit 6101e01

2 files changed

Lines changed: 193 additions & 125 deletions

File tree

components/organization/hackathons/rewards/WinnerCard.tsx

Lines changed: 114 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
'use client';
22

33
import React from 'react';
4-
import Image from 'next/image';
5-
import { ArrowUpRight } from 'lucide-react';
4+
import { Trophy, Layers } from 'lucide-react';
65
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
76
import { Badge } from '@/components/ui/badge';
87
import {
@@ -12,8 +11,7 @@ import {
1211
} from '@/components/ui/tooltip';
1312
import { cn } from '@/lib/utils';
1413
import { Submission } from './types';
15-
import Ribbon from '@/components/svg/Ribbon';
16-
import { getRibbonColors, getRibbonText } from './winnersUtils';
14+
import { getRibbonColors } from './winnersUtils';
1715

1816
interface WinnerCardProps {
1917
rank: number;
@@ -22,123 +20,141 @@ interface WinnerCardProps {
2220
currency?: string;
2321
prizeLabel?: string;
2422
maxRank: number;
23+
/**
24+
* Track name to render as the primary badge instead of the
25+
* rank ribbon. When present, the card switches to track-winner
26+
* styling (no podium scaling, neutral border accent).
27+
*/
28+
trackName?: string;
2529
}
2630

31+
const formatPrize = (amount?: string, currency?: string) => {
32+
if (!amount || amount === '0' || amount === '0.00') return null;
33+
const c = currency || 'USDC';
34+
// Industry-standard format: amount first, single currency suffix.
35+
// The previous "$300 USDC" double-signed the value and read as
36+
// confusing for USDC payouts (USDC is the unit, not USD).
37+
const numeric = Number(amount);
38+
const display = Number.isFinite(numeric)
39+
? numeric.toLocaleString('en-US')
40+
: amount;
41+
return `${display} ${c}`;
42+
};
43+
44+
const ordinalSuffix = (rank: number) => {
45+
const j = rank % 10;
46+
const k = rank % 100;
47+
if (j === 1 && k !== 11) return 'st';
48+
if (j === 2 && k !== 12) return 'nd';
49+
if (j === 3 && k !== 13) return 'rd';
50+
return 'th';
51+
};
52+
2753
export default function WinnerCard({
2854
rank,
2955
winner,
3056
prizeAmount,
3157
currency,
3258
prizeLabel,
3359
maxRank,
60+
trackName,
3461
}: WinnerCardProps) {
35-
const getScaleClass = () => {
36-
if (maxRank <= 3) {
37-
if (rank === 1) return 'md:scale-110';
38-
if (rank === 2 || rank === 3) return 'md:scale-95';
39-
} else {
40-
if (rank === 1) return 'md:scale-105';
41-
}
42-
return '';
43-
};
62+
const isTrack = !!trackName;
63+
const prizeText = formatPrize(prizeAmount, currency) || prizeLabel || null;
64+
const ribbonColors = getRibbonColors(rank);
65+
66+
// Subtle scale only for overall podium (rank 1-3). Track cards stay
67+
// uniform — they're a flat sibling row, not a podium.
68+
const scaleClass =
69+
!isTrack && rank === 1 && maxRank <= 3 ? 'md:scale-105' : '';
4470

4571
return (
4672
<div
4773
className={cn(
48-
'bg-background-card relative w-fit overflow-hidden rounded-lg p-4 transition-transform',
49-
getScaleClass()
74+
'bg-background-card relative flex flex-col gap-3 overflow-hidden rounded-xl border border-white/5 p-4 transition-all hover:border-white/10',
75+
scaleClass
5076
)}
5177
>
52-
<div className='mb-3 flex items-center justify-center gap-2'>
53-
<Image
54-
src='/trophy.svg'
55-
alt='Trophy'
56-
width={16}
57-
height={16}
58-
className='h-4 w-4 text-yellow-400'
59-
/>
60-
<span className='text-primary text-base font-medium'>
61-
{prizeAmount != null && currency && prizeAmount !== '0'
62-
? `$${prizeAmount} ${currency}`
63-
: prizeLabel || 'No prize configured'}
64-
</span>
65-
</div>
66-
67-
<div className='mb-3 flex justify-center'>
68-
<Avatar className='h-16 w-16'>
69-
{winner ? (
70-
<>
71-
<AvatarImage
72-
src={winner.avatar || 'https://github.com/shadcn.png'}
73-
/>
74-
<AvatarFallback>
75-
{winner.name.charAt(0).toUpperCase()}
76-
</AvatarFallback>
77-
</>
78-
) : (
79-
<AvatarFallback className='text-2xl text-gray-500'>
80-
?
81-
</AvatarFallback>
82-
)}
83-
</Avatar>
84-
</div>
78+
{/* Header: rank/track badge + prize chip */}
79+
<div className='flex flex-wrap items-center justify-between gap-2'>
80+
{isTrack ? (
81+
<Badge
82+
variant='outline'
83+
className='border-primary/40 text-primary gap-1'
84+
>
85+
<Layers className='h-3 w-3' />
86+
{trackName}
87+
</Badge>
88+
) : (
89+
<Badge
90+
variant='outline'
91+
className='gap-1 border-yellow-500/40 bg-yellow-500/10 text-yellow-300'
92+
style={{
93+
borderColor: `${ribbonColors.primaryColor}66`,
94+
backgroundColor: `${ribbonColors.primaryColor}1A`,
95+
color: ribbonColors.primaryColor,
96+
}}
97+
>
98+
{rank}
99+
<sup className='font-semibold'>{ordinalSuffix(rank)}</sup>
100+
<span className='ml-0.5'>Place</span>
101+
</Badge>
102+
)}
85103

86-
<div className='relative mb-3 flex items-center justify-center'>
87-
<Ribbon
88-
primaryColor={getRibbonColors(rank).primaryColor}
89-
secondaryColor={getRibbonColors(rank).secondaryColor}
90-
/>
91-
<span className='absolute inset-0 -bottom-3 flex items-center justify-center text-[12px] font-black text-white'>
92-
{getRibbonText(rank)}
93-
</span>
104+
{prizeText && (
105+
<div className='flex items-center gap-1.5 rounded-full border border-[#2775CA]/20 bg-[#2775CA]/10 px-2.5 py-1'>
106+
<Trophy className='h-3.5 w-3.5 text-yellow-500' />
107+
<span className='text-[10px] font-bold tracking-wide text-white uppercase'>
108+
{prizeText}
109+
</span>
110+
</div>
111+
)}
94112
</div>
95113

96-
<div className='mb-3 text-center'>
97-
<h3 className='text-xs font-medium text-white'>
98-
{winner?.name || '?'}
99-
</h3>
100-
</div>
114+
{/* Project block: avatar + name + category */}
115+
{winner ? (
116+
<div className='flex items-center gap-3'>
117+
<Avatar className='h-12 w-12'>
118+
<AvatarImage
119+
src={winner.avatar || undefined}
120+
alt={winner.name || 'Participant'}
121+
/>
122+
<AvatarFallback className='bg-gray-800 text-sm text-white uppercase'>
123+
{winner.name?.charAt(0) || '?'}
124+
</AvatarFallback>
125+
</Avatar>
101126

102-
{winner && (
103-
<div className='flex items-center justify-between rounded-lg border border-gray-900 p-2'>
104-
<div className='grid grid-cols-[44px_1fr] grid-rows-2 gap-x-2'>
105-
<div className='row-span-2 h-11 w-11 overflow-hidden rounded'>
106-
<Image
107-
src='/bitmed.png'
108-
alt={winner.projectName}
109-
width={44}
110-
height={44}
111-
className='h-full w-full object-cover'
112-
/>
113-
</div>
114-
<div className='flex items-center gap-1'>
115-
<Tooltip>
116-
<TooltipTrigger asChild>
117-
<p className='line-clamp-1 cursor-help text-sm font-medium text-white'>
118-
{winner.projectName}
119-
</p>
120-
</TooltipTrigger>
121-
<TooltipContent side='top' className='max-w-xs'>
122-
<p className='break-words'>{winner.projectName}</p>
123-
</TooltipContent>
124-
</Tooltip>
125-
<Badge className='bg-office-brown border-office-brown-darker text-office-brown-darker rounded-[4px] border px-1 py-0.5 text-[10px] font-medium'>
126-
{winner.category || 'General'}
127-
</Badge>
128-
</div>
129-
<div className='flex items-center gap-2 text-[10px] text-gray-500'>
130-
<span>
131-
{winner.averageScore
132-
? winner.averageScore.toFixed(1)
133-
: winner.score || 0}{' '}
134-
Score
135-
</span>
136-
<div className='h-2 w-px bg-gray-900' />
137-
<span>{winner.commentCount || 0} Comments</span>
138-
<ArrowUpRight className='h-3 w-3' />
127+
<div className='min-w-0 flex-1'>
128+
<Tooltip>
129+
<TooltipTrigger asChild>
130+
<p className='line-clamp-1 cursor-help text-sm font-semibold text-white'>
131+
{winner.projectName}
132+
</p>
133+
</TooltipTrigger>
134+
<TooltipContent side='top' className='max-w-xs'>
135+
<p className='break-words'>{winner.projectName}</p>
136+
</TooltipContent>
137+
</Tooltip>
138+
<div className='mt-0.5 flex items-center gap-2 text-xs text-gray-400'>
139+
<span className='line-clamp-1'>{winner.name || 'Unknown'}</span>
140+
{winner.category && (
141+
<>
142+
<span className='text-gray-700'></span>
143+
<span className='line-clamp-1'>{winner.category}</span>
144+
</>
145+
)}
139146
</div>
140147
</div>
141148
</div>
149+
) : (
150+
<div className='flex items-center gap-3 opacity-50'>
151+
<Avatar className='h-12 w-12'>
152+
<AvatarFallback className='bg-gray-900 text-gray-500'>
153+
?
154+
</AvatarFallback>
155+
</Avatar>
156+
<div className='text-xs text-gray-500'>No winner assigned</div>
157+
</div>
142158
)}
143159
</div>
144160
);

components/organization/hackathons/rewards/WinnersGrid.tsx

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import React, { useMemo } from 'react';
4+
import { CheckCircle2, AlertTriangle } from 'lucide-react';
45
import { cn } from '@/lib/utils';
56
import { Submission } from './types';
67
import WinnerCard from './WinnerCard';
@@ -105,37 +106,87 @@ export default function WinnersGrid({
105106
return sorted;
106107
}, [displayPairs.overallPairs]);
107108

109+
// Total prize pool across all tiers (overall + track). Drives the
110+
// summary chip in the header so the organizer sees the dollar figure
111+
// they're about to commit, not just the winner count.
112+
const totalPool = useMemo(() => {
113+
return prizeTiers.reduce((sum, t) => {
114+
const amount = parseFloat(t.prizeAmount || '0');
115+
return Number.isFinite(amount) ? sum + amount : sum;
116+
}, 0);
117+
}, [prizeTiers]);
118+
const totalPoolCurrency = prizeTiers[0]?.currency || 'USDC';
119+
120+
const assignedCount = winners.length;
121+
const isComplete = assignedCount === totalTiers && totalTiers > 0;
122+
108123
return (
109-
<div className='flex flex-col gap-4'>
110-
<div className='flex items-center justify-between'>
111-
<span className='text-xs font-medium text-gray-500'>
112-
{winners.length}/{totalTiers} Winners Assigned
113-
</span>
124+
<div className='flex flex-col gap-5'>
125+
{/* Summary header: completion state + total pool. Replaces the
126+
minimal "3/8 Winners Assigned" that read as confusing in the
127+
wizard preview. */}
128+
<div className='flex flex-wrap items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2.5'>
129+
<div className='flex items-center gap-2'>
130+
{isComplete ? (
131+
<CheckCircle2 className='h-4 w-4 text-green-400' />
132+
) : (
133+
<AlertTriangle className='h-4 w-4 text-amber-400' />
134+
)}
135+
<div className='text-sm'>
136+
<span
137+
className={cn(
138+
'font-semibold',
139+
isComplete ? 'text-green-300' : 'text-amber-300'
140+
)}
141+
>
142+
{isComplete
143+
? `All ${totalTiers} winners assigned`
144+
: `${assignedCount} of ${totalTiers} winners assigned`}
145+
</span>
146+
{!isComplete && totalTiers - assignedCount > 0 && (
147+
<span className='ml-1 text-xs text-gray-500'>
148+
({totalTiers - assignedCount} unassigned)
149+
</span>
150+
)}
151+
</div>
152+
</div>
153+
{totalPool > 0 && (
154+
<div className='flex items-center gap-1.5 rounded-full border border-[#2775CA]/20 bg-[#2775CA]/10 px-3 py-1'>
155+
<span className='text-[10px] font-bold tracking-wide text-white uppercase'>
156+
{totalPool.toLocaleString('en-US')} {totalPoolCurrency} pool
157+
</span>
158+
</div>
159+
)}
114160
</div>
115161

116162
{orderedOverall.length > 0 && (
117-
<div
118-
className={cn('mb-2 grid gap-3', getGridCols(orderedOverall.length))}
119-
>
120-
{orderedOverall.map(({ key, tier, winner }) => {
121-
const prize = getPrizeForRank(tier.rank);
122-
return (
123-
<WinnerCard
124-
key={key}
125-
rank={tier.rank}
126-
winner={winner}
127-
prizeAmount={prize.amount || '0'}
128-
currency={prize.currency || 'USDC'}
129-
prizeLabel={prize.label}
130-
maxRank={totalTiers}
131-
/>
132-
);
133-
})}
163+
<div className='space-y-2'>
164+
{displayPairs.trackPairs.length > 0 && (
165+
<div className='text-xs font-semibold tracking-wide text-gray-400 uppercase'>
166+
Overall Placements
167+
</div>
168+
)}
169+
<div className={cn('grid gap-3', getGridCols(orderedOverall.length))}>
170+
{orderedOverall.map(({ key, tier, winner }) => {
171+
const prize = getPrizeForRank(tier.rank);
172+
return (
173+
<WinnerCard
174+
key={key}
175+
rank={tier.rank}
176+
winner={winner}
177+
prizeAmount={prize.amount || '0'}
178+
currency={prize.currency || 'USDC'}
179+
prizeLabel={prize.label}
180+
maxRank={totalTiers}
181+
/>
182+
);
183+
})}
184+
</div>
134185
</div>
135186
)}
136187

137188
{displayPairs.trackPairs.length > 0 && (
138-
<div className='mb-6 space-y-2'>
189+
<div className='space-y-2'>
139190
<div className='text-xs font-semibold tracking-wide text-gray-400 uppercase'>
140191
Track Winners
141192
</div>
@@ -150,15 +201,16 @@ export default function WinnersGrid({
150201
return (
151202
<WinnerCard
152203
key={key}
153-
// Synthetic rank that's outside the overall range so
154-
// the card doesn't render a crown / podium chrome
155-
// meant for ranks 1-3.
156-
rank={Math.max(totalTiers, 4)}
204+
rank={tier.rank}
157205
winner={winner}
158206
prizeAmount={prize.amount}
159207
currency={prize.currency}
160208
prizeLabel={tier.place || prize.label}
161209
maxRank={totalTiers}
210+
// The card switches to track styling when this is set:
211+
// shows the track name as a chip instead of the rank
212+
// ribbon, and uses neutral (non-podium) borders.
213+
trackName={tier.place || winner.trackName || 'Track'}
162214
/>
163215
);
164216
})}

0 commit comments

Comments
 (0)