From 9d1f1a9b3c60768f111cd68235cea127b910e870 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 3 Mar 2026 19:10:50 +0100 Subject: [PATCH] feat: enhance HackathonPageClient and UI components - Introduced primary shadow variable in globals.css for improved styling. - Simplified tab visibility logic in HackathonPageClient by removing unnecessary checks. - Updated profile-data.tsx to improve type safety and state management. - Refactored submissions page components for better readability and performance. - Enhanced navigation and sidebar components for improved user experience. - Adjusted EmptyState and Navbar components for consistent styling across the application. --- .../hackathons/[slug]/HackathonPageClient.tsx | 33 ++-- .../[id]/hackathons/[hackathonId]/page.tsx | 2 +- .../profile/[username]/profile-data.tsx | 41 +++-- app/globals.css | 2 + app/me/earnings/page.tsx | 6 +- app/me/hackathons/submissions/page.tsx | 160 ++++++++++-------- .../submissions/submission-components.tsx | 78 ++++----- app/me/layout.tsx | 61 ++++--- app/me/participating/page.tsx | 62 ++++--- components/EmptyState.tsx | 2 +- components/app-sidebar.tsx | 10 +- components/hackathons/ProgressIndicator.tsx | 6 +- .../submissions/SubmissionDetailModal.tsx | 9 +- .../hackathons/submissions/submissionTab.tsx | 2 +- .../landing-page/hackathon/HackathonCard.tsx | 87 +++++----- components/landing-page/navbar.tsx | 16 +- components/nav-main.tsx | 34 ++-- components/nav-user.tsx | 15 +- components/organization/cards/Participant.tsx | 4 +- .../cards/ParticipantSubmission.tsx | 17 +- .../hackathons/details/HackathonSelector.tsx | 23 ++- components/profile/ProfileDataClient.tsx | 16 +- components/profile/ProfileOverview.tsx | 3 +- components/profile/PublicEarningsTab.tsx | 47 ++++- hooks/hackathon/use-hackathon-transform.ts | 6 +- hooks/hackathon/use-hackathons-list.ts | 6 +- hooks/hackathon/use-organizer-submissions.ts | 22 ++- hooks/hackathon/use-submissions.ts | 5 +- hooks/use-auth.ts | 7 +- hooks/use-participant-submission.ts | 9 +- lib/api/api.ts | 6 +- lib/api/earnings.ts | 13 +- lib/api/hackathons.ts | 76 ++++++--- lib/api/types.ts | 13 +- lib/providers/hackathonProvider.tsx | 15 +- package.json | 1 + types/earnings.ts | 7 +- types/hackathon/core.ts | 3 + 38 files changed, 568 insertions(+), 357 deletions(-) diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index c486cb61..ac9860d6 100644 --- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -102,14 +102,9 @@ export default function HackathonPageClient() { const participantType = currentHackathon?.participantType; const isTeamHackathon = participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL'; - const isTabEnabled = - currentHackathon?.enabledTabs?.includes('joinATeamTab') !== false; const hasWinners = winners && winners.length > 0; - const isWinnersTabEnabled = - currentHackathon?.enabledTabs?.includes('winnersTab') !== false; - const tabs = [ { id: 'overview', label: 'Overview' }, ...(hasParticipants @@ -152,7 +147,7 @@ export default function HackathonPageClient() { }, ]; - if (isTeamHackathon && isTabEnabled) { + if (isTeamHackathon) { tabs.push({ id: 'team-formation', label: 'Find Team', @@ -160,7 +155,7 @@ export default function HackathonPageClient() { }); } - if (hasWinners && isWinnersTabEnabled) { + if (hasWinners) { tabs.push({ id: 'winners', label: 'Winners', @@ -170,7 +165,10 @@ export default function HackathonPageClient() { // Filter tabs against enabledTabs so only explicitly enabled tabs are shown. // 'overview' is always kept as it is the default fallback tab. // If enabledTabs is undefined/null (not configured), all tabs are shown as before. - // Map UI tab ids to backend enabledTabs keys where they differ. + // + // IMPORTANT: Any new tab id added to the tabs array above must have a corresponding + // entry in tabIdToEnabledKey below; otherwise it falls back to tab.id and may be + // hidden when enabledTabs is set. const tabIdToEnabledKey: Record = { 'team-formation': 'joinATeamTab', winners: 'winnersTab', @@ -181,6 +179,7 @@ export default function HackathonPageClient() { discussions: 'discussionTab', }; + /** Backend enabledTabs entry type; keys in tabIdToEnabledKey must align with this. */ type EnabledTab = NonNullable< typeof currentHackathon >['enabledTabs'][number]; @@ -191,7 +190,17 @@ export default function HackathonPageClient() { return tabs.filter(tab => { if (tab.id === 'overview') return true; const key = (tabIdToEnabledKey[tab.id] ?? tab.id) as EnabledTab; - return enabledSet.has(key); + const isVisible = enabledSet.has(key); + if ( + !isVisible && + process.env.NODE_ENV === 'development' && + currentHackathon?.enabledTabs + ) { + console.warn( + `[HackathonPageClient] Tab "${tab.id}" (enabled key: ${key}) is not in currentHackathon.enabledTabs and will be hidden. Add the tab id to tabIdToEnabledKey and ensure the backend includes the key in enabledTabs when the tab should be visible.` + ); + } + return isVisible; }); } return tabs; @@ -303,6 +312,8 @@ export default function HackathonPageClient() { // Now also defaults to 'overview' if the URL tab is not in the filtered hackathonTabs list. // This handles direct URL access to a disabled tab — user is silently redirected to overview. useEffect(() => { + if (loading || !currentHackathon) return; + const tabFromUrl = searchParams.get('tab'); // No tab in URL — default to overview @@ -322,7 +333,7 @@ export default function HackathonPageClient() { const queryParams = new URLSearchParams(searchParams.toString()); queryParams.set('tab', 'overview'); router.replace(`?${queryParams.toString()}`, { scroll: false }); - }, [searchParams, hackathonTabs, router]); + }, [searchParams, hackathonTabs, router, loading, currentHackathon]); const handleTabChange = (tabId: string) => { setActiveTab(tabId); @@ -354,7 +365,7 @@ export default function HackathonPageClient() { // Helper: checks if a tab id is present in the filtered hackathonTabs array. // Used below to guard each tab's content from rendering if the tab is disabled. - const isTabVisible = (tabId: string) => + const isTabVisible = (tabId: string): boolean => hackathonTabs.some(tab => tab.id === tabId); // Shared props for banner and sticky card diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx index d9467fed..7355366e 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx @@ -205,7 +205,7 @@ export default function HackathonPage() { {phase.description}

-
+
{new Date(phase.date).toLocaleDateString()}
diff --git a/app/(landing)/profile/[username]/profile-data.tsx b/app/(landing)/profile/[username]/profile-data.tsx index e5d4472f..30c6281b 100644 --- a/app/(landing)/profile/[username]/profile-data.tsx +++ b/app/(landing)/profile/[username]/profile-data.tsx @@ -13,6 +13,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; +import { clsx } from 'clsx'; import ActivityTab from '@/components/profile/ActivityTab'; import OrganizationsTab from '@/components/profile/OrganizationsTab'; import ProfileOverviewPublic from '@/components/profile/ProfileOverviewPublic'; @@ -36,9 +37,9 @@ const FILTER_OPTIONS = [ const TAB_CLASS = 'data-[state=active]:border-b-primary/45 rounded-none border-b-2 border-transparent bg-transparent px-0 py-3 text-sm font-medium text-zinc-500 data-[state=active]:text-white'; -export function ProfileData({ +export const ProfileData = ({ username, -}: PublicProfileDataProps): React.ReactElement { +}: PublicProfileDataProps): React.ReactElement => { const [userData, setUserData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -47,7 +48,7 @@ export function ProfileData({ const [isAuthenticated, setIsAuthenticated] = useState(false); useEffect(() => { - async function loadProfile(): Promise { + const loadProfile = async (): Promise => { try { setLoading(true); const { data: session } = await authClient.getSession(); @@ -60,11 +61,15 @@ export function ProfileData({ } finally { setLoading(false); } - } + }; loadProfile(); }, [username]); + const handleFilterSelect = (filter: string) => { + setSelectedFilter(filter); + }; + if (loading) { return (
@@ -118,12 +123,14 @@ export function ProfileData({ Earnings - - Organizations - + {isAuthenticated && isOwnProfile && ( + + Organizations + + )} @@ -148,12 +155,12 @@ export function ProfileData({ {FILTER_OPTIONS.map(filter => ( setSelectedFilter(filter)} - className={ - selectedFilter === filter - ? 'bg-zinc-800' - : 'hover:!bg-zinc-600/50 hover:!text-white' - } + onClick={() => handleFilterSelect(filter)} + className={clsx({ + 'bg-zinc-800': selectedFilter === filter, + 'hover:bg-zinc-600/50! hover:text-white!': + selectedFilter !== filter, + })} > {filter} @@ -180,4 +187,4 @@ export function ProfileData({
); -} +}; diff --git a/app/globals.css b/app/globals.css index dcf8d75f..f26eec3e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -237,6 +237,7 @@ body { --popover-foreground: oklch(0.145 0 0); --primary: #a7f950; --primary-foreground: oklch(0.205 0 0); + --primary-shadow: 0 2px 8px rgba(167, 249, 80, 0.2); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); @@ -290,6 +291,7 @@ body { --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); + --primary-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); diff --git a/app/me/earnings/page.tsx b/app/me/earnings/page.tsx index 7174a242..3c0c7f7c 100644 --- a/app/me/earnings/page.tsx +++ b/app/me/earnings/page.tsx @@ -81,9 +81,7 @@ const BreakdownItem: React.FC = ({ {label} - - ${(Number(value) || 0).toLocaleString()} - + ${(value ?? 0).toLocaleString()} ); @@ -109,7 +107,7 @@ const ActivityItem: React.FC = ({ activity }) => (

- ${(Number(activity.amount) || 0).toLocaleString()} + ${(activity.amount ?? 0).toLocaleString()}

{activity.currency && (

{activity.currency}

diff --git a/app/me/hackathons/submissions/page.tsx b/app/me/hackathons/submissions/page.tsx index 107919de..767c32af 100644 --- a/app/me/hackathons/submissions/page.tsx +++ b/app/me/hackathons/submissions/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, type FC } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useAuthStatus } from '@/hooks/use-auth'; import BoundlessSheet from '@/components/sheet/boundless-sheet'; @@ -23,7 +23,39 @@ import { TableRow, } from './submission-components'; -export default function SubmissionsPage() { +/** API shape for a submission as returned from profile / user endpoints. */ +interface RawSubmission { + id?: string; + _id?: string; + projectName?: string; + title?: string; + name?: string; + description?: string; + introduction?: string; + logo?: string; + videoUrl?: string; + category?: string; + links?: Array<{ type: string; url: string }>; + status?: string; + rank?: number | null; + submittedAt?: string; + submissionDate?: string; + createdAt?: string; + votes?: number | unknown[]; + comments?: number | unknown[]; + hackathon?: SubmissionRow['hackathon']; + disqualificationReason?: string; +} + +/** Profile shape that may expose submissions under user or at profile level. */ +interface UserProfile { + user?: { + hackathonSubmissionsAsParticipant?: RawSubmission[]; + }; + hackathonSubmissionsAsParticipant?: RawSubmission[]; +} + +const SubmissionsPage: FC = () => { const router = useRouter(); const { user, isLoading } = useAuthStatus(); @@ -35,42 +67,43 @@ export default function SubmissionsPage() { // Pull submissions data from auth state — no extra API calls const rawSubmissions: SubmissionRow[] = useMemo(() => { - const profile = (user as any)?.profile; + const profile = (user?.profile ?? undefined) as UserProfile | undefined; if (!profile) return []; - // Primary path: profile.user.hackathonSubmissionsAsParticipant - const fromUser: any[] = - profile?.user?.hackathonSubmissionsAsParticipant || []; - // Secondary alias (some API shapes expose it at profile level) - const fromProfile: any[] = profile?.hackathonSubmissionsAsParticipant || []; + const fromUser: RawSubmission[] = + profile.user?.hackathonSubmissionsAsParticipant ?? []; + const fromProfile: RawSubmission[] = + profile.hackathonSubmissionsAsParticipant ?? []; - // Merge & deduplicate by id - const merged = [...fromUser, ...fromProfile]; + const merged: RawSubmission[] = [...fromUser, ...fromProfile]; const seen = new Set(); - const deduped = merged.filter(s => { - const id = s?.id || s?._id; + const deduped = merged.filter((s: RawSubmission) => { + const id = s.id ?? s._id; if (!id || seen.has(id)) return false; seen.add(id); return true; }); - return deduped.map((s: any) => ({ - id: s.id || s._id || '', - projectName: s.projectName || s.title || s.name || 'Untitled Submission', - description: s.description, - introduction: s.introduction, - logo: s.logo, - videoUrl: s.videoUrl, - category: s.category, - links: s.links, - status: s.status || 'draft', - rank: s.rank ?? null, - submittedAt: s.submittedAt || s.submissionDate || s.createdAt || '', - votes: s.votes, - comments: s.comments, - hackathon: s.hackathon, - disqualificationReason: s.disqualificationReason, - })); + return deduped.map( + (s: RawSubmission): SubmissionRow => ({ + id: s.id ?? s._id ?? '', + projectName: + s.projectName ?? s.title ?? s.name ?? 'Untitled Submission', + description: s.description, + introduction: s.introduction, + logo: s.logo, + videoUrl: s.videoUrl, + category: s.category, + links: s.links, + status: s.status ?? 'draft', + rank: s.rank ?? null, + submittedAt: s.submittedAt ?? s.submissionDate ?? s.createdAt ?? '', + votes: s.votes, + comments: s.comments, + hackathon: s.hackathon, + disqualificationReason: s.disqualificationReason, + }) + ); }, [user]); const sorted = useMemo(() => { @@ -109,6 +142,30 @@ export default function SubmissionsPage() { }); }, [rawSubmissions, sortField, sortDir]); + const summaryStats = useMemo(() => { + const ranked = rawSubmissions.filter(s => { + const st = (s.status || '').toLowerCase(); + return st === 'ranked' || st === 'shortlisted' || st === 'winner'; + }).length; + const underReview = rawSubmissions.filter(s => { + const st = (s.status || '').toLowerCase().replace(/[\s\-_]+/g, '_'); + return st === 'under_review' || st === 'submitted'; + }).length; + const draft = rawSubmissions.filter( + s => (s.status || '').toLowerCase() === 'draft' + ).length; + return [ + { + label: 'Total', + value: rawSubmissions.length, + color: 'text-white', + }, + { label: 'Ranked', value: ranked, color: 'text-primary' }, + { label: 'Under Review', value: underReview, color: 'text-amber-400' }, + { label: 'Draft', value: draft, color: 'text-zinc-400' }, + ] as const; + }, [rawSubmissions]); + const handleSort = (field: SortField) => { if (sortField === field) { setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); @@ -165,45 +222,10 @@ export default function SubmissionsPage() { animate={{ opacity: 1 }} transition={{ delay: 0.15 }} > - {( - [ - { - label: 'Total', - value: rawSubmissions.length, - color: 'text-white', - }, - { - label: 'Ranked', - value: rawSubmissions.filter(s => { - const st = (s.status || '').toLowerCase(); - return ( - st === 'ranked' || st === 'shortlisted' || st === 'winner' - ); - }).length, - color: 'text-primary', - }, - { - label: 'Under Review', - value: rawSubmissions.filter(s => { - const st = (s.status || '') - .toLowerCase() - .replace(/[\s\-_]+/g, '_'); - return st === 'under_review' || st === 'submitted'; - }).length, - color: 'text-amber-400', - }, - { - label: 'Draft', - value: rawSubmissions.filter( - s => (s.status || '').toLowerCase() === 'draft' - ).length, - color: 'text-zinc-400', - }, - ] as const - ).map(stat => ( + {summaryStats.map(stat => (
{stat.value} @@ -223,7 +245,7 @@ export default function SubmissionsPage() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} - className='overflow-hidden rounded-xl border border-white/5 bg-white/[0.025]' + className='overflow-hidden rounded-xl border border-white/5 bg-white/2.5' >
@@ -367,4 +389,6 @@ export default function SubmissionsPage() { ); -} +}; + +export default SubmissionsPage; diff --git a/app/me/hackathons/submissions/submission-components.tsx b/app/me/hackathons/submissions/submission-components.tsx index 2a6704b8..3e636b4c 100644 --- a/app/me/hackathons/submissions/submission-components.tsx +++ b/app/me/hackathons/submissions/submission-components.tsx @@ -17,11 +17,13 @@ import { Separator } from '@/components/ui/separator'; import { TableCell, TableRow as ShadcnTableRow } from '@/components/ui/table'; import Image from 'next/image'; import { format } from 'date-fns'; -import { useState } from 'react'; +import React, { useState } from 'react'; // ─────────────────────────── Url Sanitization ─────────────────────────── -export function getSafeUrl(urlString?: string): string | undefined { +export const getSafeUrl: ( + urlString?: string +) => string | undefined = urlString => { if (!urlString) return undefined; try { const parsed = new URL(urlString); @@ -32,7 +34,16 @@ export function getSafeUrl(urlString?: string): string | undefined { } catch { return undefined; } -} +}; + +const formatDate = (dateString?: string): string => { + if (!dateString) return '—'; + try { + return format(new Date(dateString), 'MMM d, yyyy'); + } catch { + return dateString; + } +}; export type SortField = | 'projectName' @@ -54,8 +65,8 @@ export type SubmissionRow = { status: string; rank?: number | null; submittedAt: string; - votes?: number | any[]; - comments?: number | any[]; + votes?: number | unknown[]; + comments?: number | unknown[]; hackathon?: { id?: string; title?: string; @@ -64,14 +75,15 @@ export type SubmissionRow = { submissionDeadline?: string; banner?: string; }; + disqualificationReason?: string; }; // ─────────────────────────── Status badge ─────────────────────────── -export function getStatusConfig(status: string): { +export const getStatusConfig: (status: string) => { label: string; className: string; -} { +} = status => { const s = (status || '').toLowerCase(); if ( @@ -112,9 +124,9 @@ export function getStatusConfig(status: string): { : s.charAt(0).toUpperCase() + s.slice(1) || 'Draft', className: 'text-gray-400 bg-gray-800/20', }; -} +}; -export function StatusBadge({ status }: { status: string }) { +export const StatusBadge: React.FC<{ status: string }> = ({ status }) => { const cfg = getStatusConfig(status); return ( ); -} +}; // ─────────────────────────── Sort icon ─────────────────────────── @@ -169,17 +181,16 @@ export function SubmissionsSheetContent({ ? submission.comments.length : 0; - const formatDate = (dateString?: string) => { - if (!dateString) return '—'; - try { - return format(new Date(dateString), 'MMM d, yyyy'); - } catch { - return dateString; - } - }; - const viewUrl = `/projects/${submission.id}?type=submission`; + const safeVideoUrl = submission.videoUrl + ? getSafeUrl(submission.videoUrl) + : null; + + const handleViewClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + return (
{/* Header */} @@ -199,7 +210,7 @@ export function SubmissionsSheetContent({ href={viewUrl} target='_blank' rel='noopener noreferrer' - onClick={e => e.stopPropagation()} + onClick={handleViewClick} className='inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-white/20 hover:text-white' aria-label='View submission page' > @@ -234,7 +245,7 @@ export function SubmissionsSheetContent({ )} {/* Metadata strip */} -
+
{submission.rank != null && ( @@ -272,7 +283,7 @@ export function SubmissionsSheetContent({ submission.hackathon?.submissionDeadline) && (
{submission.hackathon.startDate && ( -
+

Hackathon Start

{formatDate(submission.hackathon.startDate)} @@ -280,7 +291,7 @@ export function SubmissionsSheetContent({

)} {submission.hackathon.submissionDeadline && ( -
+

Submission Deadline

{formatDate(submission.hackathon.submissionDeadline)} @@ -317,13 +328,13 @@ export function SubmissionsSheetContent({ )} {/* Video link */} - {submission.videoUrl && getSafeUrl(submission.videoUrl) && ( + {safeVideoUrl && (

Demo Video

{link.type || link.url} @@ -363,13 +374,13 @@ export function SubmissionsSheetContent({ {/* Disqualification reason */} {(submission.status || '').toLowerCase() === 'disqualified' && - (submission as any).disqualificationReason && ( + submission.disqualificationReason && (

Disqualification Reason

- {(submission as any).disqualificationReason} + {submission.disqualificationReason}

)} @@ -392,15 +403,6 @@ export function TableRow({ const viewUrl = `/projects/${submission.id}?type=submission`; const [isHoverOrFocus, setIsHoverOrFocus] = useState(false); - const formatDate = (dateString?: string) => { - if (!dateString) return '—'; - try { - return format(new Date(dateString), 'MMM d, yyyy'); - } catch { - return dateString; - } - }; - const handleLeftClick = (e: React.MouseEvent) => { // Left click only if (e.button !== 0) return; @@ -431,7 +433,7 @@ export function TableRow({ transition={{ delay: index * 0.03, duration: 0.25 }} onClick={handleLeftClick} onAuxClick={handleAuxClick} - className='group cursor-pointer border-b border-white/5 transition-colors duration-150 hover:bg-white/[0.04]' + className='group cursor-pointer border-b border-white/5 transition-colors duration-150 hover:bg-white/4' onMouseEnter={() => setIsHoverOrFocus(true)} onMouseLeave={() => setIsHoverOrFocus(false)} role='button' diff --git a/app/me/layout.tsx b/app/me/layout.tsx index 5309c061..a2ad6e87 100644 --- a/app/me/layout.tsx +++ b/app/me/layout.tsx @@ -7,43 +7,60 @@ import { useAuthStatus } from '@/hooks/use-auth'; import React, { useMemo } from 'react'; import LoadingSpinner from '@/components/LoadingSpinner'; -export default function MeLayout({ children }: { children: React.ReactNode }) { +interface MeLayoutProps { + children: React.ReactNode; +} + +/** Item with optional id or _id for join/submission arrays from profile API. */ +interface ProfileItemWithId { + id?: string; + _id?: string; +} + +/** Profile shape used by layout: user + optional root-level submission list. */ +interface MeLayoutProfile { + user?: { + image?: string; + joinedHackathons?: ProfileItemWithId[]; + hackathonSubmissionsAsParticipant?: ProfileItemWithId[]; + }; + image?: string; + hackathonSubmissionsAsParticipant?: ProfileItemWithId[]; +} + +const getId = (item: ProfileItemWithId): string | undefined => + item.id ?? item._id; + +const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { const { user, isLoading } = useAuthStatus(); const { name = '', email = '', profile, image: userImage = '' } = user || {}; + const typedProfile = profile as MeLayoutProfile | null | undefined; const userData = { name: name || '', email, - image: - (profile as any)?.user?.image || (profile as any)?.image || userImage, + image: typedProfile?.user?.image ?? typedProfile?.image ?? userImage ?? '', }; const hackathonsCount = useMemo(() => { - if (!profile) return 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const joined = (profile as any)?.user?.joinedHackathons || []; - return joined.length; - }, [profile]); + if (!typedProfile?.user?.joinedHackathons) return 0; + return typedProfile.user.joinedHackathons.length; + }, [typedProfile]); const submissionsCount = useMemo(() => { - if (!profile) return 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fromUser = - (profile as any)?.user?.hackathonSubmissionsAsParticipant || []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fromProfile = - (profile as any)?.hackathonSubmissionsAsParticipant || []; - // Deduplicate by id before counting - const merged = [...fromUser, ...fromProfile]; + if (!typedProfile) return 0; + const fromUser = typedProfile.user?.hackathonSubmissionsAsParticipant ?? []; + const fromProfile = typedProfile.hackathonSubmissionsAsParticipant ?? []; + const merged: ProfileItemWithId[] = [...fromUser, ...fromProfile]; const seen = new Set(); - return merged.filter((s: any) => { - const id = s?.id || s?._id; + return merged.filter(s => { + const id = getId(s); if (!id || seen.has(id)) return false; seen.add(id); return true; }).length; - }, [profile]); + }, [typedProfile]); if (isLoading) { return ( @@ -76,4 +93,6 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { ); -} +}; + +export default MeLayout; diff --git a/app/me/participating/page.tsx b/app/me/participating/page.tsx index 142b2613..2f09b83c 100644 --- a/app/me/participating/page.tsx +++ b/app/me/participating/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStatus } from '@/hooks/use-auth'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -16,11 +16,24 @@ import EmptyState from '@/components/EmptyState'; type TabType = 'all' | 'hackathons' | 'projects'; +/** API shape: joined hackathon can be wrapper or raw hackathon. */ +type JoinedHackathonRow = { hackathon?: Hackathon } | Hackathon; + +/** API shape: participant record with nested hackathon. */ +interface HackathonParticipantRow { + hackathon: Hackathon; +} + +/** API shape: submission record with nested hackathon. */ +interface HackathonSubmissionRow { + hackathon: Hackathon; +} + interface UnifiedItem extends Hackathon { type: 'hackathon'; } -export default function ParticipatingPage() { +const ParticipatingPage: React.FC = () => { const router = useRouter(); const { user, isLoading } = useAuthStatus(); const [activeTab, setActiveTab] = useState('all'); @@ -35,42 +48,38 @@ export default function ParticipatingPage() { return []; } - const joinedHackathons = profile.user?.joinedHackathons || []; - const hackathonsAsParticipant = profile.hackathonsAsParticipant || []; - const submissions = profile.user?.hackathonSubmissionsAsParticipant || []; + const joinedHackathons = (profile.user?.joinedHackathons || + []) as JoinedHackathonRow[]; + const hackathonsAsParticipant = (profile.hackathonsAsParticipant || + []) as HackathonParticipantRow[]; + const submissions = (profile.user?.hackathonSubmissionsAsParticipant || + []) as HackathonSubmissionRow[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const typedJoinedHackathons: UnifiedItem[] = joinedHackathons - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((h: any) => { - const data = h?.hackathon || h; - return data && data.id; + .filter((h: JoinedHackathonRow) => { + const data = + (h as { hackathon?: Hackathon }).hackathon ?? (h as Hackathon); + return data?.id != null; }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((h: any) => { - const hackathonData = h.hackathon || h; + .map((h: JoinedHackathonRow) => { + const hackathonData = + (h as { hackathon?: Hackathon }).hackathon ?? (h as Hackathon); return { ...hackathonData, type: 'hackathon' as const, }; }); - // Map hackathons from participating list — filter first to ensure p.hackathon is defined const typedParticipatingHackathons: UnifiedItem[] = hackathonsAsParticipant - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((p: any) => p && p.hackathon && p.hackathon.id) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((p: any) => ({ + .filter((p: HackathonParticipantRow) => p?.hackathon?.id != null) + .map((p: HackathonParticipantRow) => ({ ...p.hackathon, type: 'hackathon' as const, })); - // Map hackathons from submissions const typedSubmissionHackathons: UnifiedItem[] = submissions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((s: any) => s.hackathon) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((s: any) => ({ + .filter((s: HackathonSubmissionRow) => s?.hackathon != null) + .map((s: HackathonSubmissionRow) => ({ ...s.hackathon, type: 'hackathon' as const, })); @@ -89,9 +98,9 @@ export default function ParticipatingPage() { return true; }); + const now = Date.now(); const sorted = deduplicated.sort((a, b) => { const getPriority = (h: UnifiedItem) => { - const now = new Date().getTime(); if (!h.startDate || !h.submissionDeadline) return 1; const start = new Date(h.startDate).getTime(); @@ -112,6 +121,7 @@ export default function ParticipatingPage() { if (activeTab === 'projects') return []; let result = unifiedList; + // Intentional: filter by type so that when UnifiedItem gains 'project' entries, this tab shows only hackathons. if (activeTab === 'hackathons') { result = unifiedList.filter(item => item.type === 'hackathon'); } @@ -253,4 +263,6 @@ export default function ParticipatingPage() {
); -} +}; + +export default ParticipatingPage; diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx index ba50d859..5869929e 100644 --- a/components/EmptyState.tsx +++ b/components/EmptyState.tsx @@ -70,7 +70,7 @@ const EmptyState: React.FC = ({ case 'custom': return `${baseStyle} px-6 py-3 bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 shadow-md`; default: - return `${baseStyle} px-6 py-3 bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-primary shadow-[0_2px_8px_rgba(167,249,80,0.2)]`; + return `${baseStyle} px-6 py-3 bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-primary shadow-[var(--primary-shadow)]`; } }; diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 63b9e338..320a8c6b 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -68,8 +68,8 @@ const getNavigationData = (counts?: { url: '/me/participating', icon: IconShieldCheck, badge: - counts?.participating && counts.participating > 0 - ? counts.participating.toString() + (counts?.participating ?? 0) > 0 + ? String(counts?.participating) : undefined, }, { @@ -77,8 +77,8 @@ const getNavigationData = (counts?: { url: '/me/hackathons/submissions', icon: IconUsers, badge: - counts?.submissions && counts.submissions > 0 - ? counts.submissions.toString() + (counts?.submissions ?? 0) > 0 + ? String(counts?.submissions) : undefined, }, ], @@ -125,7 +125,7 @@ export function AppSidebar({ } & React.ComponentProps) { const navigationData = React.useMemo( () => getNavigationData(counts), - [counts] + [counts?.participating, counts?.submissions] ); return ( diff --git a/components/hackathons/ProgressIndicator.tsx b/components/hackathons/ProgressIndicator.tsx index 98748abd..fc4fc0ee 100644 --- a/components/hackathons/ProgressIndicator.tsx +++ b/components/hackathons/ProgressIndicator.tsx @@ -36,10 +36,10 @@ const STAGE_CONFIG: Record = }, }; -export function ProgressIndicator({ +export const ProgressIndicator: React.FC = ({ stage, className, -}: ProgressIndicatorProps) { +}) => { const config = STAGE_CONFIG[stage]; return ( @@ -54,4 +54,4 @@ export function ProgressIndicator({ {config.label}
); -} +}; diff --git a/components/hackathons/submissions/SubmissionDetailModal.tsx b/components/hackathons/submissions/SubmissionDetailModal.tsx index 2c3aa81a..42ac9375 100644 --- a/components/hackathons/submissions/SubmissionDetailModal.tsx +++ b/components/hackathons/submissions/SubmissionDetailModal.tsx @@ -146,7 +146,7 @@ export function SubmissionDetailModal({ return ( - + {isLoading ? 'Loading...' : submission?.projectName} @@ -272,7 +272,12 @@ export function SubmissionDetailModal({
- Submitted: {formatDate(submission.submissionDate)} + + Submitted:{' '} + {formatDate( + submission.submissionDate ?? submission.submittedAt ?? '' + )} +
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index 9bc23455..e803750f 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -81,7 +81,7 @@ const SubmissionTabContent: React.FC = ({ setSelectedSort, setSelectedCategory, } = useSubmissions(); - + console.log({ submissions }); const { currentHackathon } = useHackathonData(); const { status } = useHackathonStatus( currentHackathon?.startDate, diff --git a/components/landing-page/hackathon/HackathonCard.tsx b/components/landing-page/hackathon/HackathonCard.tsx index f8a2856b..305124ac 100644 --- a/components/landing-page/hackathon/HackathonCard.tsx +++ b/components/landing-page/hackathon/HackathonCard.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useRouter } from 'nextjs-toploader/app'; +import Link from 'next/link'; import Image from 'next/image'; import { MapPinIcon } from 'lucide-react'; import { useEffect, useState, useCallback } from 'react'; @@ -118,6 +118,38 @@ import { cn } from '@/lib/utils'; const formatFullNumber = (num: number): string => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(num); +const MAX_VISIBLE_CATEGORIES = 3; + +interface CategoriesDisplayProps { + categoriesList?: string[]; +} + +const CategoriesDisplay = ({ categoriesList = [] }: CategoriesDisplayProps) => { + const visible = categoriesList.slice(0, MAX_VISIBLE_CATEGORIES); + const remainingCount = categoriesList.length - MAX_VISIBLE_CATEGORIES; + + return ( +
+
+ {visible.map((cat, i) => ( + + {cat} + + ))} + + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+
+ ); +}; + interface TimeRemaining { days: number; hours: number; @@ -186,7 +218,6 @@ export const HackathonCard = ({ className, target, }: HackathonCardProps) => { - const router = useRouter(); const [timeRemaining, setTimeRemaining] = useState({ days: 0, hours: 0, @@ -194,15 +225,6 @@ export const HackathonCard = ({ seconds: 0, total: 0, }); - const handleClick = () => { - const slugPath = slug || id || ''; - const url = `/hackathons/${slugPath}`; - if (target === '_blank') { - window.open(url, '_blank'); - } else { - router.push(url); - } - }; // Determine top badge status using raw dates — memoised so it can safely // appear in the useEffect dependency array without triggering infinite loops. @@ -345,7 +367,7 @@ export const HackathonCard = ({ return () => clearInterval(interval); } - }, [status, startDate, submissionDeadline, getTopBadgeStatus]); + }, [startDate, submissionDeadline, getTopBadgeStatus]); const bottomStatusInfo = getBottomStatusInfo(); const topBadgeStatus = getTopBadgeStatus(); @@ -364,46 +386,19 @@ export const HackathonCard = ({ // return undefined; // })(); - const CategoriesDisplay = ({ - categoriesList = [], - }: { - categoriesList?: string[]; - }) => { - const MAX_VISIBLE = 3; - - const visible = categoriesList.slice(0, MAX_VISIBLE); - const remainingCount = categoriesList.length - MAX_VISIBLE; - - return ( -
-
- {visible.map((cat, i) => ( - - {cat} - - ))} - - {remainingCount > 0 && ( - - +{remainingCount} - - )} -
-
- ); - }; + const href = `/hackathons/${slug || id || ''}`; return ( -
{/* Image */}
@@ -495,7 +490,7 @@ export const HackathonCard = ({ )} */}
-
+ ); }; diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 00b6cd84..66e0ac34 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -55,11 +55,13 @@ interface UserProfile { } interface User { + id?: string; username?: string | null; name?: string | null; email?: string | null; image?: string | null; - profile?: UserProfile; + role?: string; + profile?: UserProfile | import('@/lib/api/types').GetMeResponse | null; } export function Navbar() { @@ -72,7 +74,7 @@ export function Navbar() { } return ( -