@@ -70,7 +92,10 @@ const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => {
variant='outline'
size='sm'
className='h-11 shrink-0 rounded-xl border-white/10 bg-white/5 px-4 text-sm font-bold text-white transition-all hover:bg-white/10'
- onClick={e => onMessageLeader(team, e.currentTarget)}
+ onClick={e => {
+ e.stopPropagation();
+ onMessageLeader(team, e.currentTarget);
+ }}
aria-label='Message team leader'
>
@@ -81,7 +106,10 @@ const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => {
variant='outline'
size='sm'
className='border-primary/20 text-primary hover:bg-primary h-11 w-full rounded-xl bg-[#232B20]/30 px-6 text-sm font-bold transition-all hover:text-black sm:w-auto'
- onClick={() => onJoin?.(team)}
+ onClick={e => {
+ e.stopPropagation();
+ onJoin?.(team);
+ }}
disabled={memberCount >= maxSize}
>
Join Team
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx
index 051605d14..1678d4e49 100644
--- a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx
+++ b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx
@@ -14,7 +14,10 @@ import FindTeam from './contents/FindTeam';
import SponsorsTab from './contents/SponsorsTab';
import { useEffect, useState, useMemo } from 'react';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
-import { useHackathonAnnouncements } from '@/hooks/hackathon/use-hackathon-queries';
+import {
+ useHackathonAnnouncements,
+ useHackathonTeams,
+} from '@/hooks/hackathon/use-hackathon-queries';
import { useCommentSystem } from '@/hooks/use-comment-system';
import { CommentEntityType } from '@/types/comment';
import { Megaphone } from 'lucide-react';
@@ -46,6 +49,17 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => {
enabled: !!currentHackathon?.id,
});
+ const participantType = currentHackathon?.participantType;
+ const isTeamHackathon =
+ participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL';
+
+ const { data: teamsCountResponse } = useHackathonTeams(
+ slug,
+ { page: 1, limit: 1 },
+ !!slug && isTeamHackathon
+ );
+ const teamsTotal = teamsCountResponse?.data?.pagination?.total ?? 0;
+
const [activeTab, setActiveTab] = useState('overview');
const hackathonTabs = useMemo(() => {
@@ -55,10 +69,6 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => {
const hasWinners = !!(winners && winners.length > 0);
const hasAnnouncements = announcements.length > 0;
- const participantType = currentHackathon.participantType;
- const isTeamHackathon =
- participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL';
-
const tabs = [
{ id: 'overview', label: 'Overview' },
...(hasParticipants
@@ -105,6 +115,7 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => {
tabs.push({
id: 'team-formation',
label: 'Find Team',
+ badge: teamsTotal,
});
}
@@ -159,6 +170,8 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => {
announcements,
announcementsLoading,
generalLoading,
+ isTeamHackathon,
+ teamsTotal,
]);
useEffect(() => {
diff --git a/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx b/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx
new file mode 100644
index 000000000..d5ee5b4f2
--- /dev/null
+++ b/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx
@@ -0,0 +1,253 @@
+'use client';
+
+import { use } from 'react';
+import Link from 'next/link';
+import { useHackathon, useTeam } from '@/hooks/hackathon/use-hackathon-queries';
+import { Button } from '@/components/ui/button';
+import BasicAvatar from '@/components/avatars/BasicAvatar';
+import {
+ ArrowLeft,
+ Calendar,
+ Crown,
+ Loader2,
+ Mail,
+ Users,
+ XCircle,
+} from 'lucide-react';
+
+export default function TeamDetailsPage({
+ params,
+}: {
+ params: Promise<{ slug: string; teamId: string }>;
+}) {
+ const { slug, teamId } = use(params);
+
+ const { data: hackathon } = useHackathon(slug);
+ const { data: team, isLoading, isError } = useTeam(slug, teamId);
+
+ const formattedCreatedAt = team?.createdAt
+ ? new Date(team.createdAt).toLocaleDateString('en-US', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric',
+ })
+ : '';
+
+ const status = team?.isOpen ? 'OPEN' : 'CLOSED';
+
+ return (
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ Loading team...
+
+
+ ) : isError || !team ? (
+
+
+
+
Team not found
+
+ This team may have been disbanded or never existed.
+
+
+
+ ) : (
+
+
+
+
+
+ {team.teamName.charAt(0).toUpperCase()}
+
+
+
+ {team.teamName}
+
+
+
+ {status}
+
+
+
+ {team.memberCount}/{team.maxSize} builders
+
+ {formattedCreatedAt && (
+
+
+ Created {formattedCreatedAt}
+
+ )}
+
+
+
+
+
+ {team.description && (
+
+ {team.description}
+
+ )}
+
+
+
+
+
+
+ Members
+
+
+ {team.members && team.members.length > 0 ? (
+ team.members.map(member => {
+ const isLeader = member.userId === team.leader?.id;
+ const profileHref = member.username
+ ? `/profile/${member.username}`
+ : null;
+ const content = (
+ <>
+
+
+ {isLeader && (
+
+
+ Leader
+
+ )}
+
+ {!isLeader && (
+
+ {member.role}
+
+ )}
+ >
+ );
+ return profileHref ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ );
+ })
+ ) : (
+
+ No members listed.
+
+ )}
+
+
+
+ {team.lookingFor && team.lookingFor.length > 0 && (
+
+
+ Roles Needed
+
+
+ {team.lookingFor.map((role, idx) => (
+
+ {role.role}
+ {role.skills && role.skills.length > 0 && (
+
+ {role.skills.join(', ')}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ {team.leader && (
+
+
+ Team Leader
+
+ {team.leader.username ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {team.contactInfo && (
+
+
+ Contact
+
+
+
+ {team.contactInfo}
+
+ {team.contactMethod && (
+
+ Via {team.contactMethod}
+
+ )}
+
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
index 3423f4af0..5a2d9f71c 100644
--- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx
@@ -128,6 +128,10 @@ const ParticipantsPage: React.FC = () => {
const [statistics, setStatistics] = useState<{
participantsCount: number;
submissionsCount: number;
+ soloParticipants: number;
+ totalTeams: number;
+ soloSubmissions: number;
+ teamSubmissions: number;
} | null>(null);
const [statisticsLoading, setStatisticsLoading] = useState(false);
@@ -143,10 +147,18 @@ const ParticipantsPage: React.FC = () => {
const stats = response.data as {
totalSubmissions?: number;
activeParticipants?: number;
+ soloParticipants?: number;
+ totalTeams?: number;
+ soloSubmissions?: number;
+ teamSubmissions?: number;
};
setStatistics({
participantsCount: stats.activeParticipants ?? 0,
submissionsCount: stats.totalSubmissions ?? 0,
+ soloParticipants: stats.soloParticipants ?? 0,
+ totalTeams: stats.totalTeams ?? 0,
+ soloSubmissions: stats.soloSubmissions ?? 0,
+ teamSubmissions: stats.teamSubmissions ?? 0,
});
} catch (err) {
reportError(err, {
@@ -277,7 +289,7 @@ const ParticipantsPage: React.FC = () => {
}>
-
+
{
}
showTrend={true}
/>
+
+
(null);
const [hackathon, setHackathon] = useState(null);
+ const [submissionStats, setSubmissionStats] = useState<{
+ soloSubmissions: number;
+ teamSubmissions: number;
+ totalSubmissions: number;
+ } | null>(null);
+ const [submissionStatsLoading, setSubmissionStatsLoading] = useState(false);
useEffect(() => {
if (hackathonId) {
@@ -58,6 +66,35 @@ export default function SubmissionsPage() {
}
}, [hackathonId]);
+ useEffect(() => {
+ if (!organizationId || !hackathonId) return;
+ const loadStats = async () => {
+ setSubmissionStatsLoading(true);
+ try {
+ const res = await getHackathonStatistics(organizationId, hackathonId);
+ const stats = res.data as {
+ totalSubmissions?: number;
+ soloSubmissions?: number;
+ teamSubmissions?: number;
+ };
+ setSubmissionStats({
+ totalSubmissions: stats.totalSubmissions ?? 0,
+ soloSubmissions: stats.soloSubmissions ?? 0,
+ teamSubmissions: stats.teamSubmissions ?? 0,
+ });
+ } catch (err) {
+ reportError(err, {
+ context: 'org-submissions-fetchStats',
+ organizationId,
+ hackathonId,
+ });
+ } finally {
+ setSubmissionStatsLoading(false);
+ }
+ };
+ loadStats();
+ }, [organizationId, hackathonId]);
+
useEffect(() => {
const fetchSession = async () => {
try {
@@ -142,6 +179,36 @@ export default function SubmissionsPage() {
) : (
+
+
+
+
+
+
([]);
+ const [hackathonSlug, setHackathonSlug] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [submissionFilter, setSubmissionFilter] =
+ useState('all');
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [totalPages, setTotalPages] = useState(0);
+ const [totalTeams, setTotalTeams] = useState(0);
+
+ useEffect(() => {
+ if (!hackathonId) return;
+ let cancelled = false;
+ const load = async () => {
+ setLoading(true);
+ try {
+ const limit = Math.min(pageSize, MAX_PAGE_SIZE);
+ const teamsRes = await getTeams(hackathonId, {
+ page,
+ limit,
+ search: search.trim() || undefined,
+ openOnly: false,
+ });
+ if (cancelled) return;
+ setTeams(teamsRes?.data?.teams ?? []);
+ setTotalPages(teamsRes?.data?.pagination?.totalPages ?? 0);
+ setTotalTeams(teamsRes?.data?.pagination?.total ?? 0);
+ } catch (err) {
+ reportError(err, {
+ context: 'organizer-teams-load',
+ organizationId,
+ hackathonId,
+ });
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+ load();
+ return () => {
+ cancelled = true;
+ };
+ }, [hackathonId, organizationId, page, pageSize, search]);
+
+ useEffect(() => {
+ if (!hackathonId) return;
+ let cancelled = false;
+ const loadHackathon = async () => {
+ try {
+ const hackRes = await getHackathon(hackathonId);
+ if (!cancelled) {
+ setHackathonSlug(hackRes?.data?.slug ?? hackathonId);
+ }
+ } catch (err) {
+ reportError(err, {
+ context: 'organizer-teams-load-hackathon',
+ organizationId,
+ hackathonId,
+ });
+ if (!cancelled) setHackathonSlug(hackathonId);
+ }
+ };
+ loadHackathon();
+ return () => {
+ cancelled = true;
+ };
+ }, [hackathonId, organizationId]);
+
+ const filteredTeams = useMemo(() => {
+ return teams.filter(team => {
+ if (statusFilter === 'open' && !team.isOpen) return false;
+ if (statusFilter === 'closed' && team.isOpen) return false;
+ if (submissionFilter === 'submitted' && !team.hasSubmission) return false;
+ if (submissionFilter === 'not_submitted' && team.hasSubmission)
+ return false;
+ return true;
+ });
+ }, [teams, statusFilter, submissionFilter]);
+
+ const table = useReactTable({
+ data: teams,
+ columns: [],
+ getCoreRowModel: getCoreRowModel(),
+ manualPagination: true,
+ pageCount: totalPages,
+ state: {
+ pagination: {
+ pageIndex: page - 1,
+ pageSize,
+ },
+ },
+ onPaginationChange: updater => {
+ if (typeof updater === 'function') {
+ const next = updater({ pageIndex: page - 1, pageSize });
+ if (next.pageSize !== pageSize) {
+ setPageSize(Math.min(next.pageSize, MAX_PAGE_SIZE));
+ setPage(1);
+ } else {
+ setPage(next.pageIndex + 1);
+ }
+ }
+ },
+ });
+
+ const stats = useMemo(() => {
+ const submittedOnPage = teams.filter(t => t.hasSubmission).length;
+ const openOnPage = teams.filter(t => t.isOpen).length;
+ return {
+ total: totalTeams,
+ open: openOnPage,
+ submitted: submittedOnPage,
+ notSubmitted: teams.length - submittedOnPage,
+ };
+ }, [teams, totalTeams]);
+
+ return (
+ }>
+
+
+
+
+ Teams
+
+
+ All teams formed for this hackathon and whether they have
+ submitted.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setSearch(e.target.value);
+ setPage(1);
+ }}
+ className='focus:border-primary focus:ring-primary border-gray-700/50 bg-gray-900/50 pl-10 text-white placeholder:text-gray-500'
+ />
+
+
+
+
+
+
+
+ {loading ? (
+
+ ) : filteredTeams.length === 0 ? (
+
+
+
+ {teams.length === 0
+ ? 'No teams yet for this hackathon.'
+ : 'No teams match the current filters.'}
+
+
+ ) : (
+
+
+
Team
+
Leader
+
Members
+
Submission
+
Status
+
+
+ {filteredTeams.map(team => {
+ const href = `/hackathons/${hackathonSlug}/teams/${team.id}`;
+ return (
+ -
+
+
+
+
+ {team.teamName?.charAt(0).toUpperCase()}
+
+
+
+ {team.teamName}
+
+
+ Created{' '}
+ {new Date(team.createdAt).toLocaleDateString()}
+
+
+
+
+
+ {team.leader ? (
+
+
+
+
+ ) : (
+
+ No leader
+
+ )}
+
+
+
+
+ {team.memberCount}/{team.maxSize}
+
+
+
+ {team.hasSubmission ? (
+
+
+ Submitted
+
+ ) : (
+
+
+ Pending
+
+ )}
+
+
+
+ {team.isOpen ? 'Open' : 'Closed'}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/me/hackathons/submissions/submission-components.tsx b/app/me/hackathons/submissions/submission-components.tsx
index 378c5b661..c55dab34e 100644
--- a/app/me/hackathons/submissions/submission-components.tsx
+++ b/app/me/hackathons/submissions/submission-components.tsx
@@ -227,7 +227,7 @@ export function SubmissionsSheetContent({
src={submission.hackathon?.banner || submission.logo!}
alt={submission.projectName}
fill
- className='object-cover opacity-80'
+ className='object-contain opacity-80'
/>
{submission.logo && submission.hackathon?.banner && (
@@ -236,7 +236,7 @@ export function SubmissionsSheetContent({
src={submission.logo}
alt='Project logo'
fill
- className='object-cover'
+ className='object-contain'
/>
@@ -450,7 +450,7 @@ export function TableRow({
src={submission.logo}
alt={submission.projectName}
fill
- className='object-cover'
+ className='object-contain'
/>
) : (
diff --git a/components/organization/hackathons/details/HackathonSidebar.tsx b/components/organization/hackathons/details/HackathonSidebar.tsx
index 42da982fb..ec66f48a7 100644
--- a/components/organization/hackathons/details/HackathonSidebar.tsx
+++ b/components/organization/hackathons/details/HackathonSidebar.tsx
@@ -3,6 +3,7 @@ import {
Settings,
LayoutDashboard,
Users,
+ UsersRound,
BarChartBig,
Megaphone,
FileText,
@@ -321,6 +322,16 @@ export default function HackathonSidebar({
description: 'View all submissions',
disabled: hackathonId?.startsWith('draft-'),
},
+ {
+ icon: UsersRound,
+ label: 'Teams',
+ href:
+ basePath !== '#' && !hackathonId?.startsWith('draft-')
+ ? `${basePath}/teams`
+ : '#',
+ description: 'View all teams',
+ disabled: hackathonId?.startsWith('draft-'),
+ },
{
icon: BarChartBig,
label: 'Judging',
diff --git a/components/organization/hackathons/submissions/SubmissionsList.tsx b/components/organization/hackathons/submissions/SubmissionsList.tsx
index c1f414858..30029d9a1 100644
--- a/components/organization/hackathons/submissions/SubmissionsList.tsx
+++ b/components/organization/hackathons/submissions/SubmissionsList.tsx
@@ -16,6 +16,7 @@ import { reportError } from '@/lib/error-reporting';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
+import { BoundlessButton } from '@/components/buttons/BoundlessButton';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
@@ -157,10 +158,6 @@ export function SubmissionsList({
router.push(`/projects/${submissionId}?type=submission`);
};
- const isBeforeDeadline = hackathon?.submissionDeadline
- ? new Date() < new Date(hackathon.submissionDeadline)
- : false;
-
if (submissions.length === 0 && !loading) {
return (
@@ -287,49 +284,45 @@ export function SubmissionsList({
onClick={e => e.stopPropagation()}
>
{subData.status === 'SUBMITTED' ? (
-
+ : 'Approve'}
+
) : (
-
+
)}
{onDisqualify && (
-
+
)}
)}
@@ -506,18 +499,13 @@ export function SubmissionsList({
onClick={e =>
handleReview(e, subData.id, 'SHORTLISTED')
}
- disabled={
- reviewingId === subData.id ||
- isBeforeDeadline
- }
- className={`cursor-pointer text-green-500 focus:bg-green-900/20 focus:text-green-400 ${isBeforeDeadline ? 'cursor-not-allowed opacity-50' : ''}`}
+ disabled={reviewingId === subData.id}
+ className='text-primary focus:bg-primary/10 focus:text-primary cursor-pointer'
>
{reviewingId === subData.id
? 'Approving...'
- : isBeforeDeadline
- ? 'Before Deadline'
- : 'Approve'}
+ : 'Approve'}
) : (
-
-
-
+
) : (
diff --git a/components/project-details/project-sidebar/ProjectSidebarHeader.tsx b/components/project-details/project-sidebar/ProjectSidebarHeader.tsx
index 8ff1b2f7f..070386341 100644
--- a/components/project-details/project-sidebar/ProjectSidebarHeader.tsx
+++ b/components/project-details/project-sidebar/ProjectSidebarHeader.tsx
@@ -42,7 +42,7 @@ export function ProjectSidebarHeader({
alt={project.title}
width={80}
height={80}
- className='relative h-20 w-20 rounded-xl object-cover ring-2 ring-gray-800/50'
+ className='relative h-20 w-20 rounded-xl object-contain ring-2 ring-gray-800/50'
/>
diff --git a/hooks/hackathon/use-hackathon-queries.ts b/hooks/hackathon/use-hackathon-queries.ts
index a588d4daa..af8bad539 100644
--- a/hooks/hackathon/use-hackathon-queries.ts
+++ b/hooks/hackathon/use-hackathon-queries.ts
@@ -91,6 +91,8 @@ export const hackathonKeys = {
// a 4-element key that does NOT prefix-match cached ['…',slug,{...params}].
teamsBase: (idOrSlug: string) => ['hackathon', 'teams', idOrSlug] as const,
myTeam: (idOrSlug: string) => ['hackathon', 'myTeam', idOrSlug] as const,
+ team: (idOrSlug: string, teamId: string) =>
+ ['hackathon', 'team', idOrSlug, teamId] as const,
myInvitations: (idOrSlug: string, status?: string) =>
['hackathon', 'myInvitations', idOrSlug, status] as const,
teamInvitations: (idOrSlug: string, teamId: string, status?: string) =>
@@ -301,6 +303,20 @@ export function useHackathonTeams(
// Legacy alias
export const useTeamPosts = useHackathonTeams;
+/**
+ * Fetch a single team by id.
+ */
+export function useTeam(idOrSlug: string, teamId: string, enabled = true) {
+ return useQuery({
+ queryKey: hackathonKeys.team(idOrSlug, teamId),
+ queryFn: async () => {
+ const response = await getTeamDetails(idOrSlug, teamId);
+ return response.data;
+ },
+ enabled: !!idOrSlug && !!teamId && enabled,
+ });
+}
+
/**
* Fetch current user's team.
*/
diff --git a/lib/api/hackathons/teams.ts b/lib/api/hackathons/teams.ts
index 083dfa5b4..a031c2b88 100644
--- a/lib/api/hackathons/teams.ts
+++ b/lib/api/hackathons/teams.ts
@@ -50,6 +50,7 @@ export interface Team {
organizationId?: string;
views?: number;
contactCount?: number;
+ hasSubmission?: boolean;
createdAt: string;
updatedAt: string;
posts?: Team[];