|
1 | | -import { redirect } from 'next/navigation'; |
| 1 | +'use client'; |
2 | 2 |
|
3 | | -const page = () => { |
4 | | - redirect('/coming-soon'); |
5 | | -}; |
| 3 | +import { useState, useMemo } from 'react'; |
| 4 | +import { motion, AnimatePresence } from 'framer-motion'; |
| 5 | +import { useAuthStatus } from '@/hooks/use-auth'; |
| 6 | +import BoundlessSheet from '@/components/sheet/boundless-sheet'; |
| 7 | +import EmptyState from '@/components/EmptyState'; |
| 8 | +import { useRouter } from 'next/navigation'; |
| 9 | +import { Trophy } from 'lucide-react'; |
| 10 | +import { |
| 11 | + Table, |
| 12 | + TableBody, |
| 13 | + TableHead, |
| 14 | + TableHeader, |
| 15 | + TableRow as ShadcnTableRow, |
| 16 | +} from '@/components/ui/table'; |
| 17 | +import { |
| 18 | + SortField, |
| 19 | + SortDir, |
| 20 | + SubmissionRow, |
| 21 | + SortIcon, |
| 22 | + SubmissionsSheetContent, |
| 23 | + TableRow, |
| 24 | +} from './submission-components'; |
6 | 25 |
|
7 | | -export default page; |
| 26 | +export default function SubmissionsPage() { |
| 27 | + const router = useRouter(); |
| 28 | + const { user, isLoading } = useAuthStatus(); |
| 29 | + |
| 30 | + const [sortField, setSortField] = useState<SortField>('submittedAt'); |
| 31 | + const [sortDir, setSortDir] = useState<SortDir>('desc'); |
| 32 | + const [selectedSubmission, setSelectedSubmission] = |
| 33 | + useState<SubmissionRow | null>(null); |
| 34 | + const [sheetOpen, setSheetOpen] = useState(false); |
| 35 | + |
| 36 | + // Pull submissions data from auth state — no extra API calls |
| 37 | + const rawSubmissions: SubmissionRow[] = useMemo(() => { |
| 38 | + const profile = (user as any)?.profile; |
| 39 | + if (!profile) return []; |
| 40 | + |
| 41 | + // Primary path: profile.user.hackathonSubmissionsAsParticipant |
| 42 | + const fromUser: any[] = |
| 43 | + profile?.user?.hackathonSubmissionsAsParticipant || []; |
| 44 | + // Secondary alias (some API shapes expose it at profile level) |
| 45 | + const fromProfile: any[] = profile?.hackathonSubmissionsAsParticipant || []; |
| 46 | + |
| 47 | + // Merge & deduplicate by id |
| 48 | + const merged = [...fromUser, ...fromProfile]; |
| 49 | + const seen = new Set<string>(); |
| 50 | + const deduped = merged.filter(s => { |
| 51 | + const id = s?.id || s?._id; |
| 52 | + if (!id || seen.has(id)) return false; |
| 53 | + seen.add(id); |
| 54 | + return true; |
| 55 | + }); |
| 56 | + |
| 57 | + return deduped.map((s: any) => ({ |
| 58 | + id: s.id || s._id || '', |
| 59 | + projectName: s.projectName || s.title || s.name || 'Untitled Submission', |
| 60 | + description: s.description, |
| 61 | + introduction: s.introduction, |
| 62 | + logo: s.logo, |
| 63 | + videoUrl: s.videoUrl, |
| 64 | + category: s.category, |
| 65 | + links: s.links, |
| 66 | + status: s.status || 'draft', |
| 67 | + rank: s.rank ?? null, |
| 68 | + submittedAt: s.submittedAt || s.submissionDate || s.createdAt || '', |
| 69 | + votes: s.votes, |
| 70 | + comments: s.comments, |
| 71 | + hackathon: s.hackathon, |
| 72 | + disqualificationReason: s.disqualificationReason, |
| 73 | + })); |
| 74 | + }, [user]); |
| 75 | + |
| 76 | + const sorted = useMemo(() => { |
| 77 | + return [...rawSubmissions].sort((a, b) => { |
| 78 | + let aVal: any; |
| 79 | + let bVal: any; |
| 80 | + |
| 81 | + switch (sortField) { |
| 82 | + case 'projectName': |
| 83 | + aVal = (a.projectName || '').toLowerCase(); |
| 84 | + bVal = (b.projectName || '').toLowerCase(); |
| 85 | + break; |
| 86 | + case 'hackathon': |
| 87 | + aVal = (a.hackathon?.title || a.hackathon?.name || '').toLowerCase(); |
| 88 | + bVal = (b.hackathon?.title || b.hackathon?.name || '').toLowerCase(); |
| 89 | + break; |
| 90 | + case 'status': |
| 91 | + aVal = (a.status || '').toLowerCase(); |
| 92 | + bVal = (b.status || '').toLowerCase(); |
| 93 | + break; |
| 94 | + case 'submittedAt': |
| 95 | + aVal = a.submittedAt ? new Date(a.submittedAt).getTime() : 0; |
| 96 | + bVal = b.submittedAt ? new Date(b.submittedAt).getTime() : 0; |
| 97 | + break; |
| 98 | + case 'rank': |
| 99 | + aVal = a.rank ?? Infinity; |
| 100 | + bVal = b.rank ?? Infinity; |
| 101 | + break; |
| 102 | + default: |
| 103 | + return 0; |
| 104 | + } |
| 105 | + |
| 106 | + if (aVal < bVal) return sortDir === 'asc' ? -1 : 1; |
| 107 | + if (aVal > bVal) return sortDir === 'asc' ? 1 : -1; |
| 108 | + return 0; |
| 109 | + }); |
| 110 | + }, [rawSubmissions, sortField, sortDir]); |
| 111 | + |
| 112 | + const handleSort = (field: SortField) => { |
| 113 | + if (sortField === field) { |
| 114 | + setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); |
| 115 | + } else { |
| 116 | + setSortField(field); |
| 117 | + setSortDir('asc'); |
| 118 | + } |
| 119 | + }; |
| 120 | + |
| 121 | + const handleRowClick = (submission: SubmissionRow) => { |
| 122 | + setSelectedSubmission(submission); |
| 123 | + setSheetOpen(true); |
| 124 | + }; |
| 125 | + |
| 126 | + const getAriaSort = (field: SortField) => { |
| 127 | + if (sortField !== field) return 'none'; |
| 128 | + return sortDir === 'asc' ? 'ascending' : 'descending'; |
| 129 | + }; |
| 130 | + |
| 131 | + const thClass = |
| 132 | + 'h-12 px-2 text-left align-middle font-medium text-zinc-400 [&:has([role=checkbox])]:pr-0'; |
| 133 | + |
| 134 | + if (isLoading) { |
| 135 | + return ( |
| 136 | + <div className='flex h-[400px] items-center justify-center'> |
| 137 | + <div className='border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent' /> |
| 138 | + </div> |
| 139 | + ); |
| 140 | + } |
| 141 | + |
| 142 | + return ( |
| 143 | + <div className='container mx-auto max-w-7xl px-4 py-8 md:px-6 lg:py-12'> |
| 144 | + {/* Page header */} |
| 145 | + <motion.div |
| 146 | + className='mb-10' |
| 147 | + initial={{ opacity: 0, y: -12 }} |
| 148 | + animate={{ opacity: 1, y: 0 }} |
| 149 | + transition={{ duration: 0.35 }} |
| 150 | + > |
| 151 | + <h1 className='text-3xl font-bold tracking-tight text-white md:text-4xl'> |
| 152 | + My Submissions |
| 153 | + </h1> |
| 154 | + <p className='mt-2 text-zinc-400'> |
| 155 | + Track the full lifecycle of every hackathon submission you've |
| 156 | + made. |
| 157 | + </p> |
| 158 | + </motion.div> |
| 159 | + |
| 160 | + {/* Summary strip */} |
| 161 | + {rawSubmissions.length > 0 && ( |
| 162 | + <motion.div |
| 163 | + className='mb-6 flex flex-wrap gap-4' |
| 164 | + initial={{ opacity: 0 }} |
| 165 | + animate={{ opacity: 1 }} |
| 166 | + transition={{ delay: 0.15 }} |
| 167 | + > |
| 168 | + {( |
| 169 | + [ |
| 170 | + { |
| 171 | + label: 'Total', |
| 172 | + value: rawSubmissions.length, |
| 173 | + color: 'text-white', |
| 174 | + }, |
| 175 | + { |
| 176 | + label: 'Ranked', |
| 177 | + value: rawSubmissions.filter(s => { |
| 178 | + const st = (s.status || '').toLowerCase(); |
| 179 | + return ( |
| 180 | + st === 'ranked' || st === 'shortlisted' || st === 'winner' |
| 181 | + ); |
| 182 | + }).length, |
| 183 | + color: 'text-primary', |
| 184 | + }, |
| 185 | + { |
| 186 | + label: 'Under Review', |
| 187 | + value: rawSubmissions.filter(s => { |
| 188 | + const st = (s.status || '') |
| 189 | + .toLowerCase() |
| 190 | + .replace(/[\s\-_]+/g, '_'); |
| 191 | + return st === 'under_review' || st === 'submitted'; |
| 192 | + }).length, |
| 193 | + color: 'text-amber-400', |
| 194 | + }, |
| 195 | + { |
| 196 | + label: 'Draft', |
| 197 | + value: rawSubmissions.filter( |
| 198 | + s => (s.status || '').toLowerCase() === 'draft' |
| 199 | + ).length, |
| 200 | + color: 'text-zinc-400', |
| 201 | + }, |
| 202 | + ] as const |
| 203 | + ).map(stat => ( |
| 204 | + <div |
| 205 | + key={stat.label} |
| 206 | + className='flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.03] px-4 py-2 text-sm' |
| 207 | + > |
| 208 | + <span className={`text-lg font-bold ${stat.color}`}> |
| 209 | + {stat.value} |
| 210 | + </span> |
| 211 | + <span className='text-zinc-500'>{stat.label}</span> |
| 212 | + </div> |
| 213 | + ))} |
| 214 | + </motion.div> |
| 215 | + )} |
| 216 | + |
| 217 | + {/* Table */} |
| 218 | + <AnimatePresence mode='wait'> |
| 219 | + {sorted.length > 0 ? ( |
| 220 | + <motion.div |
| 221 | + key='table' |
| 222 | + initial={{ opacity: 0, y: 10 }} |
| 223 | + animate={{ opacity: 1, y: 0 }} |
| 224 | + exit={{ opacity: 0 }} |
| 225 | + transition={{ duration: 0.3 }} |
| 226 | + className='overflow-hidden rounded-xl border border-white/5 bg-white/[0.025]' |
| 227 | + > |
| 228 | + <div className='overflow-x-auto'> |
| 229 | + <Table className='min-w-[560px]'> |
| 230 | + <TableHeader> |
| 231 | + <ShadcnTableRow className='border-white/5 hover:bg-transparent'> |
| 232 | + <TableHead |
| 233 | + className={`${thClass} pr-3 pl-4`} |
| 234 | + aria-sort={getAriaSort('projectName')} |
| 235 | + > |
| 236 | + <button |
| 237 | + type='button' |
| 238 | + onClick={() => handleSort('projectName')} |
| 239 | + className='flex w-full items-center gap-1 rounded-sm text-xs font-semibold tracking-wider uppercase hover:text-white focus-visible:ring-2 focus-visible:ring-[#a7f950] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0e0c0c] focus-visible:outline-none' |
| 240 | + aria-label='Sort by Project Name' |
| 241 | + > |
| 242 | + Project |
| 243 | + <SortIcon |
| 244 | + field='projectName' |
| 245 | + sortField={sortField} |
| 246 | + sortDir={sortDir} |
| 247 | + /> |
| 248 | + </button> |
| 249 | + </TableHead> |
| 250 | + <TableHead |
| 251 | + className={`${thClass} hidden px-3 sm:table-cell`} |
| 252 | + aria-sort={getAriaSort('hackathon')} |
| 253 | + > |
| 254 | + <button |
| 255 | + type='button' |
| 256 | + onClick={() => handleSort('hackathon')} |
| 257 | + className='flex w-full items-center gap-1 rounded-sm text-xs font-semibold tracking-wider uppercase hover:text-white focus-visible:ring-2 focus-visible:ring-[#a7f950] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0e0c0c] focus-visible:outline-none' |
| 258 | + aria-label='Sort by Hackathon' |
| 259 | + > |
| 260 | + Hackathon |
| 261 | + <SortIcon |
| 262 | + field='hackathon' |
| 263 | + sortField={sortField} |
| 264 | + sortDir={sortDir} |
| 265 | + /> |
| 266 | + </button> |
| 267 | + </TableHead> |
| 268 | + <TableHead |
| 269 | + className={`${thClass} px-3`} |
| 270 | + aria-sort={getAriaSort('status')} |
| 271 | + > |
| 272 | + <button |
| 273 | + type='button' |
| 274 | + onClick={() => handleSort('status')} |
| 275 | + className='flex w-full items-center gap-1 rounded-sm text-xs font-semibold tracking-wider uppercase hover:text-white focus-visible:ring-2 focus-visible:ring-[#a7f950] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0e0c0c] focus-visible:outline-none' |
| 276 | + aria-label='Sort by Status' |
| 277 | + > |
| 278 | + Status |
| 279 | + <SortIcon |
| 280 | + field='status' |
| 281 | + sortField={sortField} |
| 282 | + sortDir={sortDir} |
| 283 | + /> |
| 284 | + </button> |
| 285 | + </TableHead> |
| 286 | + <TableHead |
| 287 | + className={`${thClass} hidden px-3 lg:table-cell`} |
| 288 | + aria-sort={getAriaSort('submittedAt')} |
| 289 | + > |
| 290 | + <button |
| 291 | + type='button' |
| 292 | + onClick={() => handleSort('submittedAt')} |
| 293 | + className='flex w-full items-center gap-1 rounded-sm text-xs font-semibold tracking-wider uppercase hover:text-white focus-visible:ring-2 focus-visible:ring-[#a7f950] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0e0c0c] focus-visible:outline-none' |
| 294 | + aria-label='Sort by Submitted Date' |
| 295 | + > |
| 296 | + Submitted |
| 297 | + <SortIcon |
| 298 | + field='submittedAt' |
| 299 | + sortField={sortField} |
| 300 | + sortDir={sortDir} |
| 301 | + /> |
| 302 | + </button> |
| 303 | + </TableHead> |
| 304 | + <TableHead |
| 305 | + className={`${thClass} hidden px-3 xl:table-cell`} |
| 306 | + aria-sort={getAriaSort('rank')} |
| 307 | + > |
| 308 | + <button |
| 309 | + type='button' |
| 310 | + onClick={() => handleSort('rank')} |
| 311 | + className='flex w-full items-center gap-1 rounded-sm text-xs font-semibold tracking-wider uppercase hover:text-white focus-visible:ring-2 focus-visible:ring-[#a7f950] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0e0c0c] focus-visible:outline-none' |
| 312 | + aria-label='Sort by Rank' |
| 313 | + > |
| 314 | + Rank |
| 315 | + <SortIcon |
| 316 | + field='rank' |
| 317 | + sortField={sortField} |
| 318 | + sortDir={sortDir} |
| 319 | + /> |
| 320 | + </button> |
| 321 | + </TableHead> |
| 322 | + <TableHead className='py-3 pr-4 pl-3' /> |
| 323 | + </ShadcnTableRow> |
| 324 | + </TableHeader> |
| 325 | + <TableBody> |
| 326 | + {sorted.map((submission, i) => ( |
| 327 | + <TableRow |
| 328 | + key={submission.id} |
| 329 | + submission={submission} |
| 330 | + index={i} |
| 331 | + onClick={() => handleRowClick(submission)} |
| 332 | + /> |
| 333 | + ))} |
| 334 | + </TableBody> |
| 335 | + </Table> |
| 336 | + </div> |
| 337 | + </motion.div> |
| 338 | + ) : ( |
| 339 | + <motion.div |
| 340 | + key='empty' |
| 341 | + initial={{ opacity: 0, y: 10 }} |
| 342 | + animate={{ opacity: 1, y: 0 }} |
| 343 | + exit={{ opacity: 0 }} |
| 344 | + transition={{ duration: 0.3 }} |
| 345 | + > |
| 346 | + <EmptyState |
| 347 | + title='No submissions yet' |
| 348 | + description="You haven't made any submission to any hackathons yet. Explore open hackathons and start building!" |
| 349 | + buttonText='Explore Hackathons' |
| 350 | + onAddClick={() => router.push('/hackathons')} |
| 351 | + /> |
| 352 | + </motion.div> |
| 353 | + )} |
| 354 | + </AnimatePresence> |
| 355 | + |
| 356 | + {/* Detail sheet */} |
| 357 | + <BoundlessSheet |
| 358 | + open={sheetOpen} |
| 359 | + setOpen={setSheetOpen} |
| 360 | + title={selectedSubmission?.projectName} |
| 361 | + size='xl' |
| 362 | + minHeight='500px' |
| 363 | + > |
| 364 | + {selectedSubmission && ( |
| 365 | + <SubmissionsSheetContent submission={selectedSubmission} /> |
| 366 | + )} |
| 367 | + </BoundlessSheet> |
| 368 | + </div> |
| 369 | + ); |
| 370 | +} |
0 commit comments