Skip to content

Commit 06e038b

Browse files
Winners tab refactor (#438)
* feat: implement the participating page * fix: fix participating page and ui refinements * fix: fix sidebar active links and rstored earnings page * fix: fix coderabbit corrections * fix: fix coderabbit corrections * feat: Implement unified hackathon submissions dashbaord * fix: fix coderabbit corrections * fix: fix coderabbit corrections * fix: normalize the status by collapsing spaces,underscores,dashes before comparing * fix: normalize the status by collapsing spaces,underscores,dashes before comparing * fix: fix coderabbit corrections * fix: hackathon details winners tab refactor * fix: fix coderabbit correction * fix: fix coderabbit correction * fix: fix projec redirect in winners tab refactor for pr 438 --------- Co-authored-by: Benjtalkshow <chinedubenj@gmail.com>
1 parent 2b8401a commit 06e038b

2 files changed

Lines changed: 215 additions & 117 deletions

File tree

app/(landing)/hackathons/[slug]/HackathonPageClient.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export default function HackathonPageClient() {
159159
tabs.push({
160160
id: 'winners',
161161
label: 'Winners',
162+
badge: winners.length,
162163
});
163164
}
164165

components/hackathons/winners/WinnersTab.tsx

Lines changed: 214 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,28 @@ import React from 'react';
44
import Image from 'next/image';
55
import Link from 'next/link';
66
import { HackathonWinner } from '@/lib/api/hackathons';
7-
import { Trophy, Medal, Award } from 'lucide-react';
7+
import { Trophy, ArrowUpRight } from 'lucide-react';
88
import { motion } from 'motion/react';
9+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
10+
import { Badge } from '@/components/ui/badge';
11+
import {
12+
Tooltip,
13+
TooltipTrigger,
14+
TooltipContent,
15+
} from '@/components/ui/tooltip';
16+
import { cn } from '@/lib/utils';
17+
import Ribbon from '@/components/svg/Ribbon';
18+
import {
19+
getRibbonColors,
20+
getRibbonText,
21+
} from '@/components/organization/hackathons/rewards/winnersUtils';
922

1023
interface WinnersTabProps {
1124
winners: HackathonWinner[];
12-
hackathonSlug?: string;
25+
hackathonSlug?: string; // Intentionally retained for API consistency/future use
1326
}
1427

15-
export const WinnersTab = ({ winners, hackathonSlug }: WinnersTabProps) => {
28+
export const WinnersTab = ({ winners }: WinnersTabProps) => {
1629
if (!winners || winners.length === 0) {
1730
return (
1831
<div className='flex min-h-[400px] flex-col items-center justify-center text-center'>
@@ -28,146 +41,230 @@ export const WinnersTab = ({ winners, hackathonSlug }: WinnersTabProps) => {
2841
);
2942
}
3043

31-
// Sort by rank just in case
44+
// Sort by rank
3245
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
3346

47+
// Split into podium (1-3) and others
48+
const podiumWinners = sortedWinners.filter(w => w.rank <= 3);
49+
const otherWinners = sortedWinners.filter(w => w.rank > 3);
50+
51+
// Reorder podium to 2, 1, 3 for visual hierarchy
52+
const getPodiumOrder = (winners: HackathonWinner[]) => {
53+
if (winners.length < 2) return winners;
54+
const first = winners.find(w => w.rank === 1);
55+
const second = winners.find(w => w.rank === 2);
56+
const third = winners.find(w => w.rank === 3);
57+
58+
return [second, first, third].filter(
59+
(w): w is HackathonWinner => w !== undefined
60+
);
61+
};
62+
63+
const podiumToDisplay = getPodiumOrder(podiumWinners);
64+
65+
const getPodiumGridCols = (count: number) => {
66+
if (count === 1) return 'mx-auto max-w-sm grid-cols-1';
67+
if (count === 2) return 'mx-auto max-w-2xl grid-cols-1 md:grid-cols-2';
68+
return 'grid-cols-1 md:grid-cols-3';
69+
};
70+
3471
return (
35-
<div className='space-y-8'>
36-
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
37-
{sortedWinners.map((winner, index) => (
38-
<WinnerCard
39-
key={`${winner.rank}-${winner.projectName}`}
40-
winner={winner}
41-
index={index}
42-
hackathonSlug={hackathonSlug}
43-
/>
44-
))}
45-
</div>
72+
<div className='space-y-12 py-8'>
73+
{/* Podium Section */}
74+
{podiumToDisplay.length > 0 && (
75+
<div
76+
className={cn(
77+
'grid gap-6 md:gap-8',
78+
getPodiumGridCols(podiumToDisplay.length)
79+
)}
80+
>
81+
{podiumToDisplay.map(winner => (
82+
<WinnerCard
83+
key={`${winner.submissionId}-${winner.rank}`}
84+
winner={winner}
85+
isPodium
86+
/>
87+
))}
88+
</div>
89+
)}
90+
91+
{/* Grid Section for Ranks 4+ */}
92+
{otherWinners.length > 0 && (
93+
<div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
94+
{otherWinners.map(winner => (
95+
<WinnerCard
96+
key={`${winner.submissionId}-${winner.rank}`}
97+
winner={winner}
98+
/>
99+
))}
100+
</div>
101+
)}
46102
</div>
47103
);
48104
};
49105

50106
const WinnerCard = ({
51107
winner,
52-
index,
53-
hackathonSlug,
108+
isPodium = false,
54109
}: {
55110
winner: HackathonWinner;
56-
index: number;
57-
hackathonSlug?: string;
111+
isPodium?: boolean;
58112
}) => {
59-
const getRankIcon = (rank: number) => {
60-
switch (rank) {
61-
case 1:
62-
return <Trophy className='h-6 w-6 text-yellow-500' />;
63-
case 2:
64-
return <Medal className='h-6 w-6 text-gray-300' />;
65-
case 3:
66-
return <Medal className='h-6 w-6 text-amber-600' />;
67-
default:
68-
return <Award className='h-6 w-6 text-blue-500' />;
69-
}
113+
const getScaleClass = () => {
114+
if (!isPodium) return '';
115+
if (winner.rank === 1) return 'md:scale-110 z-10';
116+
return 'md:scale-95 opacity-90';
70117
};
71118

72-
const getRankColor = (rank: number) => {
73-
switch (rank) {
74-
case 1:
75-
return 'border-yellow-500/50 bg-yellow-500/5 hover:border-yellow-500';
76-
case 2:
77-
return 'border-gray-300/50 bg-gray-300/5 hover:border-gray-300';
78-
case 3:
79-
return 'border-amber-600/50 bg-amber-600/5 hover:border-amber-600';
80-
default:
81-
return 'border-white/10 bg-white/5 hover:border-white/20';
82-
}
83-
};
119+
const projectUrl = winner.submissionId
120+
? `/projects/${winner.submissionId}?type=submission`
121+
: winner.slug
122+
? `/projects/${winner.slug}`
123+
: winner.projectId
124+
? `/projects/${winner.projectId}`
125+
: null;
84126

85-
const CardContent = (
86-
<div
87-
className={`group relative h-full overflow-hidden rounded-xl border p-6 transition-all duration-300 ${getRankColor(
88-
winner.rank
89-
)}`}
90-
>
91-
<div className='mb-4 flex items-start justify-between'>
92-
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-white/10'>
93-
{getRankIcon(winner.rank)}
94-
</div>
95-
<div className='rounded-full bg-white/10 px-3 py-1 text-sm font-medium text-white'>
96-
Rank #{winner.rank}
97-
</div>
98-
</div>
127+
const { primaryColor, secondaryColor } = getRibbonColors(winner.rank);
99128

100-
<h3 className='mb-2 text-xl font-bold text-white group-hover:text-blue-400'>
101-
{winner.projectName}
102-
</h3>
103-
104-
<div className='mb-4 flex items-center gap-2'>
105-
<div className='flex -space-x-2'>
106-
{winner.participants.map((p, i) => (
107-
<div
108-
key={i}
109-
className='relative h-8 w-8 overflow-hidden rounded-full border-2 border-black bg-gray-800'
110-
title={p.username}
111-
>
112-
{p.avatar ? (
113-
<Image
114-
src={p.avatar}
115-
alt={p.username}
116-
fill
117-
className='object-cover'
118-
/>
119-
) : (
120-
<div className='flex h-full w-full items-center justify-center text-xs text-white'>
121-
{p.username.charAt(0).toUpperCase()}
122-
</div>
123-
)}
124-
</div>
125-
))}
129+
const ProjectContent = (
130+
<div className='flex items-center justify-between rounded-lg border border-gray-900 bg-black/20 p-2 transition-colors hover:bg-black/40'>
131+
<div className='grid grid-cols-[44px_1fr] grid-rows-2 gap-x-2'>
132+
<div className='row-span-2 h-11 w-11 overflow-hidden rounded bg-gray-800'>
133+
{/* Fallback to trophy if no project image is available in HackathonWinner type */}
134+
<div className='flex h-full w-full items-center justify-center p-2'>
135+
<Trophy className='h-6 w-6 text-yellow-500/20' />
136+
</div>
126137
</div>
127-
<span className='text-sm text-gray-400'>
128-
{winner.teamName ||
129-
(winner.participants.length === 1
130-
? winner.participants[0].username
131-
: 'Team')}
132-
</span>
133-
</div>
134-
135-
<div className='mt-auto border-t border-white/10 pt-4'>
136-
<div className='flex items-center justify-between'>
137-
<span className='text-sm text-gray-400'>Prize</span>
138-
<span className='font-semibold text-white'>{winner.prize}</span>
138+
<div className='flex items-center gap-1 overflow-hidden'>
139+
<Tooltip>
140+
<TooltipTrigger asChild>
141+
<p className='line-clamp-1 cursor-help text-sm font-medium text-white'>
142+
{winner.projectName}
143+
</p>
144+
</TooltipTrigger>
145+
<TooltipContent side='top' className='max-w-xs'>
146+
<p className='break-words'>{winner.projectName}</p>
147+
</TooltipContent>
148+
</Tooltip>
149+
<Badge className='bg-office-brown border-office-brown-darker text-office-brown-darker shrink-0 rounded-[4px] border px-1 py-0.5 text-[10px] font-medium'>
150+
Project
151+
</Badge>
152+
</div>
153+
<div className='flex items-center gap-2 text-[10px] text-gray-500'>
154+
<span>Submission</span>
155+
<div className='h-2 w-px bg-gray-900' />
156+
<ArrowUpRight className='h-3 w-3' />
139157
</div>
140158
</div>
141159
</div>
142160
);
143161

144-
if (winner.projectId) {
145-
return (
146-
<Link
147-
href={`/projects/${winner.projectId}?type=submission`}
148-
target='_blank'
149-
className='block h-full no-underline'
150-
>
151-
<motion.div
152-
initial={{ opacity: 0, y: 20 }}
153-
animate={{ opacity: 1, y: 0 }}
154-
transition={{ duration: 0.3, delay: index * 0.1 }}
155-
className='h-full'
156-
>
157-
{CardContent}
158-
</motion.div>
159-
</Link>
160-
);
161-
}
162-
163162
return (
164163
<motion.div
165164
initial={{ opacity: 0, y: 20 }}
166-
animate={{ opacity: 1, y: 0 }}
167-
transition={{ duration: 0.3, delay: index * 0.1 }}
168-
className='h-full'
165+
whileInView={{ opacity: 1, y: 0 }}
166+
viewport={{ once: true }}
167+
transition={{ duration: 0.4 }}
168+
className={cn(
169+
'bg-background-card relative w-full overflow-hidden rounded-xl border border-white/5 p-5 transition-all duration-300 hover:border-white/10',
170+
getScaleClass()
171+
)}
169172
>
170-
{CardContent}
173+
{/* Prize Header */}
174+
<div className='mb-4 flex items-center justify-center gap-2'>
175+
<div className='flex items-center gap-1.5 rounded-full border border-[#2775CA]/20 bg-[#2775CA]/10 px-3 py-1'>
176+
<Trophy className='h-4 w-4 text-yellow-500' />
177+
<span className='text-sm font-bold text-white uppercase'>
178+
{(() => {
179+
const prize = winner.prize || '';
180+
const match = prize.match(
181+
/^(?:USDC)?\s*(\d+(?:\.\d+)?)\s*(?:USDC)?$/i
182+
);
183+
return match ? `${match[1]} USDC` : prize;
184+
})()}
185+
</span>
186+
</div>
187+
</div>
188+
189+
{/* Ranks/Participants */}
190+
<div className='mb-4 flex flex-col items-center justify-center gap-3'>
191+
<div className='flex -space-x-3'>
192+
{winner.participants.map((p, i) => {
193+
const profileUrl = p.username ? `/profile/${p.username}` : null;
194+
const AvatarElement = (
195+
<Avatar
196+
className={cn(
197+
'border-background h-16 w-16 border-2 shadow-xl',
198+
profileUrl ? 'transition-transform hover:scale-105' : ''
199+
)}
200+
>
201+
<AvatarImage src={p.avatar} alt={p.username || 'Participant'} />
202+
<AvatarFallback className='bg-gray-800 text-lg text-white uppercase'>
203+
{p.username?.charAt(0) || '?'}
204+
</AvatarFallback>
205+
</Avatar>
206+
);
207+
208+
return profileUrl ? (
209+
<Link
210+
key={`${p.username}-${i}`}
211+
href={profileUrl}
212+
className='z-[1]'
213+
>
214+
{AvatarElement}
215+
</Link>
216+
) : (
217+
<div key={`${p.username}-${i}`} className='z-[1]'>
218+
{AvatarElement}
219+
</div>
220+
);
221+
})}
222+
</div>
223+
224+
{/* Ribbon */}
225+
<div className='relative flex items-center justify-center py-2'>
226+
<Ribbon
227+
primaryColor={primaryColor}
228+
secondaryColor={secondaryColor}
229+
className='w-48'
230+
/>
231+
<div className='absolute inset-0 flex items-center justify-center pb-2 pl-[1px] text-[10px] font-black tracking-tight text-white uppercase'>
232+
{getRibbonText(winner.rank)}
233+
</div>
234+
</div>
235+
236+
{/* Team/Participant Name */}
237+
<div className='text-center'>
238+
<h3 className='text-sm font-semibold text-white'>
239+
{winner.teamName ||
240+
(winner.participants.length === 1 &&
241+
winner.participants[0].username ? (
242+
<Link
243+
href={`/profile/${winner.participants[0].username}`}
244+
className='transition-colors hover:text-blue-400'
245+
>
246+
{winner.participants[0].username}
247+
</Link>
248+
) : (
249+
'Team'
250+
))}
251+
</h3>
252+
</div>
253+
</div>
254+
255+
{/* Project Link */}
256+
{projectUrl ? (
257+
<Link
258+
href={projectUrl}
259+
target='_blank'
260+
rel='noopener noreferrer'
261+
className='block no-underline'
262+
>
263+
{ProjectContent}
264+
</Link>
265+
) : (
266+
<div className='block'>{ProjectContent}</div>
267+
)}
171268
</motion.div>
172269
);
173270
};

0 commit comments

Comments
 (0)