diff --git a/.husky/pre-push b/.husky/pre-push index a4447e2c..a0b25c77 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -8,7 +8,7 @@ echo "🚀 Running pre-push checks..." # Run security audit echo "🔒 Running security audit..." -npm audit --audit-level=moderate +npm audit --omit=dev --audit-level=high # Run build check one more time # echo "🏗️ Final build check..." diff --git a/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx new file mode 100644 index 00000000..759781a5 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx @@ -0,0 +1,199 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Megaphone, ArrowLeft, Calendar, User, Pin } from 'lucide-react'; +import { + getAnnouncementDetails, + GetHackathonBySlug, + type HackathonAnnouncement, +} from '@/lib/api/hackathons/index'; +import { useMarkdown } from '@/hooks/use-markdown'; +import { BoundlessButton } from '@/components/buttons'; +import Loading from '@/components/Loading'; +import { Badge } from '@/components/ui/badge'; + +export default function AnnouncementDetailPage() { + const params = useParams(); + const router = useRouter(); + const announcementId = params.announcementId as string; + const slug = params.slug as string; + + const [announcement, setAnnouncement] = + useState(null); + const [hackathonName, setHackathonName] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDetails() { + if (!announcementId) return; + try { + setLoading(true); + const [announcementData, hackathonData] = await Promise.all([ + getAnnouncementDetails(announcementId), + GetHackathonBySlug(slug), + ]); + setAnnouncement(announcementData); + setHackathonName(hackathonData.data.name); + } catch (err) { + console.error('Failed to fetch details:', err); + setError( + 'Failed to load announcement. It may have been deleted or moved.' + ); + } finally { + setLoading(false); + } + } + fetchDetails(); + }, [announcementId, slug]); + + const { styledContent, loading: markdownLoading } = useMarkdown( + announcement?.content || '', + { + breaks: true, + gfm: true, + } + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !announcement) { + return ( +
+
+ +
+

+ Announcement Not Found +

+

+ {error || "We couldn't find the announcement you're looking for."} +

+ router.back()} variant='outline'> + + Go Back + +
+ ); + } + + return ( +
+ {/* Top Header */} +
+
+ +
+ + + {hackathonName ? `${hackathonName} • ` : ''}Announcement + +
+
+
+ +
+ {/* Title & Metadata */} +
+
+ {hackathonName && ( + + {hackathonName} + + )} + {announcement.isPinned && ( + + + Pinned + + )} + + Published + +
+ +

+ {announcement.title} +

+ +
+
+
+ {announcement.author?.name?.charAt(0) || ( + + )} +
+ + {announcement.author?.name || 'Organizer'} + +
+ +
+ + + {new Date( + announcement.publishedAt || announcement.createdAt + ).toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + })} + +
+
+
+ + {/* Content */} +
+ {markdownLoading ? ( +
+
+
+ ) : ( +
+ {styledContent} +
+ )} +
+ + {/* Footer */} +
+

End of Announcement

+

+ This announcement was published by the hackathon organizers. +

+ window.close()} + variant='outline' + size='sm' + > + Return to Hackathon + +
+
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/page.tsx b/app/(landing)/hackathons/[slug]/page.tsx index 38a0e2d6..c19cd861 100644 --- a/app/(landing)/hackathons/[slug]/page.tsx +++ b/app/(landing)/hackathons/[slug]/page.tsx @@ -23,7 +23,13 @@ import { HackathonParticipants } from '@/components/hackathons/participants/hack import { useCommentSystem } from '@/hooks/use-comment-system'; import { CommentEntityType } from '@/types/comment'; import { useTeamPosts } from '@/hooks/hackathon/use-team-posts'; +import { + listAnnouncements, + type HackathonAnnouncement, +} from '@/lib/api/hackathons/index'; import { HackathonWinner } from '@/lib/api/hackathons'; +import { Megaphone } from 'lucide-react'; +import { AnnouncementsTab } from '@/components/hackathons/announcements/AnnouncementsTab'; export default function HackathonPage() { const router = useRouter(); @@ -63,6 +69,29 @@ export default function HackathonPage() { autoFetch: !!hackathonId, }); + // Fetch announcements for public view + const [announcements, setAnnouncements] = useState( + [] + ); + const [announcementsLoading, setAnnouncementsLoading] = useState(false); + + useEffect(() => { + async function fetchAnnouncements() { + if (!hackathonId) return; + try { + setAnnouncementsLoading(true); + const data = await listAnnouncements(hackathonId); + // Only show published announcements for public view + setAnnouncements(data.filter(a => !a.isDraft)); + } catch (error) { + console.error('Failed to fetch announcements:', error); + } finally { + setAnnouncementsLoading(false); + } + } + fetchAnnouncements(); + }, [hackathonId]); + const hackathonTabs = useMemo(() => { const hasParticipants = Array.isArray(currentHackathon?.participants) && @@ -71,9 +100,7 @@ export default function HackathonPage() { const hasResources = currentHackathon?.resources?.[0]; const participantType = currentHackathon?.participantType; const isTeamHackathon = - participantType === 'TEAM' || - participantType === 'TEAM_OR_INDIVIDUAL' || - participantType === 'INDIVIDUAL'; + participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL'; const isTabEnabled = currentHackathon?.enabledTabs?.includes('joinATeamTab') !== false; @@ -110,6 +137,16 @@ export default function HackathonPage() { }, ] : []), + ...(announcements.length > 0 + ? [ + { + id: 'announcements', + label: 'Announcements', + badge: announcements.length, + icon: Megaphone, + }, + ] + : []), { id: 'submission', label: 'Submissions', @@ -149,6 +186,7 @@ export default function HackathonPage() { teamPosts.length, hackathonId, winners, + announcements, ]); // Refresh hackathon data @@ -202,8 +240,7 @@ export default function HackathonPage() { // Team formation availability const isTeamHackathon = currentHackathon?.participantType === 'TEAM' || - currentHackathon?.participantType === 'TEAM_OR_INDIVIDUAL' || - currentHackathon?.participantType === 'INDIVIDUAL'; + currentHackathon?.participantType === 'TEAM_OR_INDIVIDUAL'; const isTeamFormationEnabled = isTeamHackathon && currentHackathon?.enabledTabs?.includes('joinATeamTab') !== false; @@ -300,6 +337,7 @@ export default function HackathonPage() { isTeamFormationEnabled, registrationDeadlinePolicy: currentHackathon.registrationDeadlinePolicy, // Now matches API casing registrationDeadline: currentHackathon.registrationDeadline, + participantType: currentHackathon.participantType, onJoinClick: handleJoinClick, onLeaveClick: handleLeaveClick, isLeaving, @@ -342,7 +380,7 @@ export default function HackathonPage() { currency: tier.currency, passMark: tier.passMark, description: tier.description, - prizeAmount: tier.prizeAmount, // Keep as string to match PrizeTier interface + prizeAmount: tier.prizeAmount, }))} totalPrizePool={currentHackathon.prizeTiers .reduce( @@ -371,6 +409,13 @@ export default function HackathonPage() { )} + {activeTab === 'announcements' && announcements.length > 0 && ( + + )} + {activeTab === 'submission' && ( ( + [] + ); + const [isLoading, setIsLoading] = useState(true); + const [title, setTitle] = useState(''); const [content, setContent] = useState(''); - const [isPublishing, setIsPublishing] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const [isDraft, setIsDraft] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [announcementToDelete, setAnnouncementToDelete] = useState< + string | null + >(null); + + useEffect(() => { + fetchAnnouncements(); + }, [hackathonId]); + + const fetchAnnouncements = async () => { + try { + const data = await listAnnouncements(hackathonId); + setAnnouncements(data); + } catch (error) { + console.error('Failed to fetch announcements:', error); + toast.error('Failed to load announcements'); + } finally { + setIsLoading(false); + } + }; - const handlePublish = async () => { + const handleSave = async (draft = true) => { + if (!title.trim()) { + toast.error('Please enter a title'); + return; + } if (!content.trim()) { toast.error('Please enter announcement content'); return; } - setIsPublishing(true); + setIsSubmitting(true); try { - await api.post( - `/organizations/${organizationId}/hackathons/${hackathonId}/announcements`, - { + if (editingId) { + await updateAnnouncement(organizationId, hackathonId, editingId, { + title, + content, + isPinned, + isDraft: draft, + }); + toast.success( + draft ? 'Announcement saved as draft' : 'Announcement updated' + ); + } else { + await createAnnouncement(organizationId, hackathonId, { + title, content, - } + isPinned, + isDraft: draft, + }); + toast.success( + draft ? 'Announcement saved as draft' : 'Announcement published' + ); + } + resetForm(); + fetchAnnouncements(); + } catch (error) { + console.error('Failed to save announcement:', error); + toast.error('Failed to save announcement'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!announcementToDelete) return; + + try { + await deleteAnnouncement( + organizationId, + hackathonId, + announcementToDelete ); + toast.success('Announcement deleted'); + fetchAnnouncements(); + } catch (error) { + console.error('Failed to delete announcement:', error); + toast.error('Failed to delete announcement'); + } finally { + setIsDeleteDialogOpen(false); + setAnnouncementToDelete(null); + } + }; - toast.success('Announcement published successfully!'); - setContent(''); - } catch { + const handlePublishDraft = async (id: string) => { + try { + await publishAnnouncement(organizationId, hackathonId, id); + toast.success('Announcement published'); + fetchAnnouncements(); + } catch (error) { + console.error('Failed to publish announcement:', error); toast.error('Failed to publish announcement'); - } finally { - setIsPublishing(false); } }; + const handleEdit = (announcement: HackathonAnnouncement) => { + setEditingId(announcement.id); + setTitle(announcement.title); + setContent(announcement.content); + setIsPinned(announcement.isPinned); + setIsDraft(announcement.isDraft); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const resetForm = () => { + setEditingId(null); + setTitle(''); + setContent(''); + setIsPinned(false); + setIsDraft(true); + }; + return ( }> -
-
-
-

- Create Announcement -

-

- Share important updates, reminders, or announcements with the - community. -

+
+
+ {/* Header */} +
+
+

+ {editingId ? 'Edit Announcement' : 'Create Announcement'} +

+

+ {editingId + ? 'Update your announcement details.' + : 'Share important updates, reminders, or announcements with the community.'} +

+
+ {editingId && ( + + Cancel Edit + + )} +
+ + {/* Editor Card */} +
+
+
+ + setTitle(e.target.value)} + placeholder='Enter announcement title' + className='focus:border-primary/50 border-zinc-800 bg-zinc-950/50 text-white' + maxLength={100} + /> +
+ +
+ + +
+ +
+
+
+ + +
+ +
+
+
+ +
+ handleSave(false)} + disabled={isSubmitting} + className='bg-primary hover:bg-primary/90' + > + + {editingId ? 'Update & Publish' : 'Publish Now'} + + handleSave(true)} + disabled={isSubmitting} + className='border-zinc-800 text-zinc-300' + > + {editingId ? 'Save Draft' : 'Save as Draft'} + +
-
- + {/* List of Announcements */} +
+
+ +

Existing Announcements

+
+ + {isLoading ? ( +
+ +
+ ) : announcements.length === 0 ? ( +
+ No announcements yet. +
+ ) : ( +
+ {announcements.map(item => ( +
+ +
+ {item.isPinned && ( + + )} +

+ {item.title} +

+ {item.isDraft && ( + + Draft + + )} +
+

+ {item.content.replace(/<[^>]*>/g, '')} +

+
+ + {new Date(item.createdAt).toLocaleDateString()} + + By {item.author?.name || 'Unknown'} +
+
+ +
+ {item.isDraft && ( + + )} + + +
+
+ ))} +
+ )}
+
+
-
+ {/* Delete Confirmation Dialog */} + + + + Delete Announcement + +

+ Are you sure you want to delete this announcement? This action + cannot be undone. +

+ + setIsDeleteDialogOpen(false)} + className='border-zinc-800' + > + Cancel + - - {isPublishing ? 'Publishing...' : 'Publish Announcement'} + Delete -
-
-
+ + + ); } diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index 5643b1dd..ba90db50 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -3,152 +3,716 @@ import { useEffect, useState, useCallback } from 'react'; import MetricsCard from '@/components/organization/cards/MetricsCard'; import JudgingParticipant from '@/components/organization/cards/JudgingParticipant'; +import EmptyState from '@/components/EmptyState'; import { useParams } from 'next/navigation'; import { getJudgingSubmissions, + getJudgingCriteria, + addJudge, + removeJudge, + getHackathonJudges, + getJudgingResults, + getJudgingWinners, + publishJudgingResults, + type JudgingCriterion, type JudgingSubmission, -} from '@/lib/api/hackathons'; -import { Loader2 } from 'lucide-react'; + type JudgingResult, + type AggregatedJudgingResults, +} from '@/lib/api/hackathons/judging'; +import { getSubmissionDetails } from '@/lib/api/hackathons/participants'; +import { getOrganizationMembers } from '@/lib/api/organization'; +import { getCrowdfundingProject } from '@/features/projects/api'; +import { authClient } from '@/lib/auth-client'; +import { useOrganization } from '@/lib/providers/OrganizationProvider'; +import { Loader2, Trophy } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { AuthGuard } from '@/components/auth/AuthGuard'; import Loading from '@/components/Loading'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { JudgingCriteriaList } from '@/components/organization/hackathons/judging/JudgingCriteriaList'; +import JudgingResultsTable from '@/components/organization/hackathons/judging/JudgingResultsTable'; export default function JudgingPage() { const params = useParams(); const organizationId = params.id as string; const hackathonId = params.hackathonId as string; + const { activeOrgId, activeOrg } = useOrganization(); const [submissions, setSubmissions] = useState([]); + const [criteria, setCriteria] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [page, setPage] = useState(1); - const [pagination, setPagination] = useState({ - page: 1, - totalPages: 1, - total: 0, - limit: 10, - }); - - const fetchSubmissions = useCallback( - async (pageNum = 1) => { - if (!organizationId || !hackathonId) return; - - setIsLoading(true); + const [activeTab, setActiveTab] = useState('overview'); + const [isAddingJudge, setIsAddingJudge] = useState(false); + const [orgMembers, setOrgMembers] = useState([]); + const [currentJudges, setCurrentJudges] = useState([]); + const [isRefreshingJudges, setIsRefreshingJudges] = useState(false); + const [currentUserRole, setCurrentUserRole] = useState< + 'owner' | 'admin' | 'member' | null + >(null); + const [judgingResults, setJudgingResults] = useState([]); + const [judgingSummary, setJudgingSummary] = + useState(null); + const [isFetchingResults, setIsFetchingResults] = useState(false); + const [winners, setWinners] = useState([]); + const [isFetchingWinners, setIsFetchingWinners] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [isCurrentUserJudge, setIsCurrentUserJudge] = useState(false); + const [currentUserId, setCurrentUserId] = useState(null); + + const canManageJudges = + currentUserRole === 'owner' || currentUserRole === 'admin'; + const canPublishResults = canManageJudges && isCurrentUserJudge; + + const fetchJudges = useCallback(async () => { + // Priority: activeOrgId from context, then params.id + const targetOrgId = activeOrgId || organizationId; + if (!targetOrgId || !hackathonId) return; + + setIsRefreshingJudges(true); + let finalMembers: any[] = []; + let judges: any[] = []; + + // 1. Try to fetch organization members + try { + // First try legacy API try { - const response = await getJudgingSubmissions( - organizationId, - hackathonId, - pageNum, - 10 + const membersRes = await getOrganizationMembers(targetOrgId); + if ( + membersRes.success && + Array.isArray(membersRes.data) && + membersRes.data.length > 0 + ) { + finalMembers = membersRes.data; + } + } catch (err) { + console.warn( + 'Legacy member fetch failed, trying Better Auth fallback:', + err ); + } - if (response.success) { - setSubmissions(response.data || []); - setPagination(response.meta.pagination); - setPage(pageNum); + // If still empty, try Better Auth directly + if (finalMembers.length === 0) { + const { data: baData } = await authClient.organization.listMembers({ + query: { organizationId: targetOrgId, limit: 100 }, + }); + + if (baData?.members && Array.isArray(baData.members)) { + finalMembers = baData.members.map((m: any) => ({ + id: m.userId, + userId: m.userId, + name: m.user.name || m.user.email, + email: m.user.email, + image: m.user.image, + role: m.role, + })); } - } catch { - toast.error('Failed to load submissions'); - } finally { - setIsLoading(false); } - }, - [organizationId, hackathonId] - ); + } catch (err) { + console.error('All member fetching attempts failed:', err); + } + + // 2. Fetch judges + try { + const judgesRes = await getHackathonJudges(targetOrgId, hackathonId); + if (judgesRes.success) { + judges = judgesRes.data || []; + } + } catch (err) { + console.error('Failed to fetch judges:', err); + } + + setOrgMembers(finalMembers); + setCurrentJudges(judges); + setIsRefreshingJudges(false); + + // Determine current user role and judge status + const { data: session } = await authClient.getSession(); + const currentUserId = session?.user?.id; + if (currentUserId && finalMembers.length > 0) { + const me = finalMembers.find( + (m: any) => m.userId === currentUserId || m.id === currentUserId + ); + setCurrentUserRole(me?.role || null); + + // Check if current user is a judge + const isJudge = judges.some( + (j: any) => j.userId === currentUserId || j.id === currentUserId + ); + setIsCurrentUserJudge(isJudge); + } + + // Set current user ID for child components + if (currentUserId) { + setCurrentUserId(currentUserId); + } + }, [organizationId, hackathonId, activeOrgId]); + + const fetchResults = useCallback(async () => { + if (!organizationId || !hackathonId) return; + + setIsFetchingResults(true); + try { + const res = await getJudgingResults(organizationId, hackathonId); + + if (res.success && res.data) { + setJudgingResults(res.data.results || []); + setJudgingSummary(res.data); + } else { + setJudgingResults([]); + setJudgingSummary(null); + if (!res.success) { + toast.error((res as any).message || 'Failed to load judging results'); + } + } + } catch (error: any) { + console.error('Error fetching results:', error); + setJudgingResults([]); + setJudgingSummary(null); + toast.error( + error.response?.data?.message || + error.message || + 'Failed to load judging results' + ); + } finally { + setIsFetchingResults(false); + } + }, [organizationId, hackathonId]); + + const fetchData = useCallback(async () => { + if (!organizationId || !hackathonId) return; + + setIsLoading(true); + try { + // Fetch submissions, criteria, and judges/members + const [submissionsRes, criteriaRes] = await Promise.all([ + getJudgingSubmissions(organizationId, hackathonId, 1, 50), + getJudgingCriteria(hackathonId), + ]); + + // Trigger judges and results fetch in parallel but handle separately + fetchJudges(); + fetchResults(); + + let enrichedSubmissions: JudgingSubmission[] = []; + + if (submissionsRes.success) { + // Standard submissions endpoint returns { data: { submissions: [], pagination: {} } } + const submissionData = + (submissionsRes.data as any)?.submissions || + submissionsRes.data || + []; + const basicSubmissions = Array.isArray(submissionData) + ? submissionData + : []; + + // 2. Fetch full details for each submission to get user info + // We do this by fetching the project details, as submission endpoints lack user data + const detailsPromises = basicSubmissions.map(async (sub: any) => { + try { + // Check if we already have sufficient user data + if ( + sub.participant?.user?.profile?.firstName || + sub.participant?.name + ) + return sub; + + // Try fetch project details if we have projectId + if (sub.projectId) { + const project = await getCrowdfundingProject(sub.projectId); + if (project && project.project && project.project.creator) { + const creator = project.project.creator; + return { + ...sub, + participant: { + ...sub.participant, + // Use creator info for participant + name: creator.name, + username: creator.username, + image: creator.image, + email: creator.email, + user: { + ...sub.participant?.user, + name: creator.name, + username: creator.username, + image: creator.image, + email: creator.email, + profile: { + ...sub.participant?.user?.profile, + firstName: creator.name?.split(' ')[0] || '', + lastName: + creator.name?.split(' ').slice(1).join(' ') || '', + username: creator.username, + avatar: creator.image, + }, + }, + }, + }; + } + } + + // Fallback to submission details check if project fail or no projectId + const detailsRes = await getSubmissionDetails(sub.id); + if (detailsRes.success && detailsRes.data) { + const details = detailsRes.data as any; + return { + ...sub, + participant: { + ...sub.participant, + ...details.participant, + user: details.participant?.user || sub.participant?.user, + }, + }; + } + return sub; + } catch (err) { + console.error( + `Failed to fetch details for submission ${sub.id}`, + err + ); + return sub; + } + }); + + enrichedSubmissions = await Promise.all(detailsPromises); + setSubmissions(enrichedSubmissions); + } else { + setSubmissions([]); + } + + // Handle criteria response safely + setCriteria(Array.isArray(criteriaRes) ? criteriaRes : []); + } catch (error) { + console.error('Judging data fetch error:', error); + toast.error('Failed to load judging data'); + } finally { + setIsLoading(false); + } + }, [organizationId, hackathonId, fetchJudges, fetchResults]); useEffect(() => { - fetchSubmissions(); - }, [fetchSubmissions]); + fetchData(); + }, [fetchData]); const handleSuccess = () => { - fetchSubmissions(page); + fetchData(); + fetchResults(); // Refresh results to update metrics/table + }; + + const handleAddJudge = async (userId: string, email: string) => { + setIsAddingJudge(true); + try { + const res = await addJudge(organizationId, hackathonId, { + userId, + email, + }); + if (res.success) { + toast.success('Judge assigned successfully'); + fetchJudges(); + } else { + toast.error(res.message || 'Failed to assign judge'); + } + } catch (error: any) { + console.error('Error adding judge:', error); + toast.error( + error.response?.data?.message || + error.message || + 'Failed to assign judge' + ); + } finally { + setIsAddingJudge(false); + } + }; + + const handleRemoveJudge = async (userId: string) => { + try { + const res = await removeJudge(organizationId, hackathonId, userId); + if (res.success) { + toast.success('Judge removed successfully'); + fetchJudges(); + } else { + toast.error(res.message || 'Failed to remove judge'); + } + } catch (error: any) { + console.error('Error removing judge:', error); + toast.error( + error.response?.data?.message || + error.message || + 'Failed to remove judge' + ); + } }; - // Calculate statistics - const totalSubmissions = pagination.total; - const averageScore = - submissions.length > 0 - ? submissions.reduce((sum, sub) => { - return sum + (sub.averageScore || 0); - }, 0) / submissions.length + const fetchWinners = useCallback(async () => { + if (!organizationId || !hackathonId) return; + setIsFetchingWinners(true); + try { + const res = await getJudgingWinners(organizationId, hackathonId); + if (res.success && res.data) { + setWinners(Array.isArray(res.data) ? res.data : []); + } + } catch (error) { + console.error('Error fetching winners:', error); + } finally { + setIsFetchingWinners(false); + } + }, [organizationId, hackathonId]); + + const handlePublishResults = async () => { + setIsPublishing(true); + try { + const res = await publishJudgingResults(organizationId, hackathonId); + if (res.success) { + toast.success('Results published successfully!'); + fetchResults(); + fetchWinners(); + } else { + toast.error(res.message || 'Failed to publish results'); + } + } catch (error: any) { + toast.error(error.message || 'Failed to publish results'); + } finally { + setIsPublishing(false); + } + }; + + // Use pre-calculated statistics from the API if available, otherwise fallback to local calculation + const gradedCount = judgingSummary + ? judgingSummary.submissionsScoredCount + : judgingResults.length; + + const totalPossibleSubmissions = judgingSummary + ? judgingSummary.totalSubmissions + : submissions.length; + + const averageHackathonScore = judgingSummary + ? judgingSummary.averageScoreAcrossAll + : judgingResults.length > 0 + ? judgingResults.reduce( + (acc, curr) => acc + (curr.averageScore || 0), + 0 + ) / judgingResults.length : 0; - const totalJudges = new Set( - submissions.flatMap(sub => sub.scores.map(score => score.judge.id)) - ).size; + + const assignedJudgesCount = judgingSummary + ? judgingSummary.judgesAssigned + : currentJudges.length; return ( }> -
-
- - 0 ? averageScore.toFixed(2) : 'N/A'} - subtitle={ - submissions.length > 0 ? 'Across all submissions' : undefined - } - /> - -
- - {isLoading ? ( -
- +
+
+
+

Judging Dashboard

+

+ Manage and grade shortlisted submissions +

- ) : submissions.length === 0 ? ( -
- No shortlisted submissions found + +
+ 0 ? Math.round((gradedCount / totalPossibleSubmissions) * 100) : 0}% Completion`} + /> + +
- ) : ( - <> -
- {submissions.map(submission => ( - { + setActiveTab(value); + if (value === 'results') { + fetchResults(); + fetchWinners(); + } + }} + className='w-full' + > + + + Overview + + + Criteria + + + Judges + + + Results + + + + + {isLoading ? ( +
+ +
+ ) : submissions.length > 0 ? ( +
+ {submissions.map(submission => ( + 0} + judges={currentJudges} + isJudgesLoading={isRefreshingJudges} + currentUserId={currentUserId || undefined} + canOverrideScores={canManageJudges} + onSuccess={handleSuccess} + /> + ))} +
+ ) : ( + - ))} -
- - {/* Pagination */} - {pagination.totalPages > 1 && ( -
- - - Page {pagination.page} of {pagination.totalPages} - - + )} + + + + + + + +
+ {/* Current Judges List */} +
+

+ Current Judges + {isRefreshingJudges && ( + + )} +

+
+ {currentJudges.length === 0 ? ( + + ) : ( + currentJudges.map((judge: any, index: number) => ( +
+
+
+ {judge.image ? ( + + ) : ( +
+ {judge.name?.[0] || '?'} +
+ )} +
+
+

+ {judge.name} +

+

+ Judge {index + 1} +

+
+
+ {canManageJudges && ( + + )} +
+ )) + )} +
+
+ + {/* Org Members List - Only visible to admin/owner */} + {canManageJudges && ( +
+

+ Add from Organization Members +

+

+ Select members from your organization to assign them as + judges. +

+
+ {orgMembers.map((member: any) => { + const isAlreadyJudge = currentJudges.some( + j => j.id === member.id || j.userId === member.id + ); + return ( +
+
+
+ {member.image ? ( + + ) : ( +
+ {member.name?.[0] || + member.username?.[0] || + '?'} +
+ )} +
+
+

+ {member.name || member.username} +

+

+ {member.email} +

+
+
+ +
+ ); + })} + {orgMembers.length === 0 && !isRefreshingJudges && ( + + )} +
+
+ )}
- )} - - )} +
+ + +
+ {canPublishResults && judgingResults.length > 0 && ( +
+
+

+ Finalize Competition +

+

+ Publish the current rankings to name the winners. +

+
+ +
+ )} + + {winners.length > 0 && ( +
+

+ + Final Winners +

+ +
+ )} + +
+

+ Current Standings +

+ {isFetchingResults ? ( +
+ +
+ ) : judgingResults.length > 0 ? ( + + ) : ( + + )} +
+
+
+ +
); diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx index 7e02bb40..f1616cb1 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx @@ -1,13 +1,15 @@ 'use client'; import { useParams } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Loader2, AlertCircle } from 'lucide-react'; import { useHackathonSubmissions } from '@/hooks/hackathon/use-hackathon-submissions'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; import { SubmissionsManagement } from '@/components/organization/hackathons/submissions/SubmissionsManagement'; +import { authClient } from '@/lib/auth-client'; +import { getHackathon, type Hackathon } from '@/lib/api/hackathons'; export default function SubmissionsPage() { const params = useParams(); @@ -26,12 +28,40 @@ export default function SubmissionsPage() { refresh, } = useHackathonSubmissions(hackathonId); + const [currentUserId, setCurrentUserId] = useState(null); + const [hackathon, setHackathon] = useState(null); + useEffect(() => { if (hackathonId) { fetchSubmissions(); + const fetchHackathonDetails = async () => { + try { + const res = await getHackathon(hackathonId); + if (res.success && res.data) { + setHackathon(res.data); + } + } catch (err) { + console.error('Failed to fetch hackathon details:', err); + } + }; + fetchHackathonDetails(); } }, [hackathonId, fetchSubmissions]); + useEffect(() => { + const fetchSession = async () => { + try { + const { data: session } = await authClient.getSession(); + if (session?.user?.id) { + setCurrentUserId(session.user.id); + } + } catch (err) { + console.error('Failed to fetch session:', err); + } + }; + fetchSession(); + }, []); + if (error) { return (
@@ -84,6 +114,8 @@ export default function SubmissionsPage() { onRefresh={refresh} organizationId={organizationId} hackathonId={hackathonId} + currentUserId={currentUserId || undefined} + hackathon={hackathon || undefined} /> )}
diff --git a/app/(landing)/organizations/[id]/hackathons/page.tsx b/app/(landing)/organizations/[id]/hackathons/page.tsx index 1f0fb3e0..699c3905 100644 --- a/app/(landing)/organizations/[id]/hackathons/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/page.tsx @@ -41,8 +41,7 @@ const calculateDraftCompletion = (draft: HackathonDraft): number => { draft.data.information?.categories, draft.data.timeline?.startDate, draft.data.timeline?.submissionDeadline, - draft.data.timeline?.judgingDate, - draft.data.timeline?.winnerAnnouncementDate, + draft.data.timeline?.judgingStart || draft.data.timeline?.judgingDate, draft.data.timeline?.timezone, draft.data.participation?.participantType, draft.data.rewards?.prizeTiers?.length, @@ -402,7 +401,12 @@ export default function HackathonsPage() { ? (hackathon as HackathonDraft).data.timeline ?.submissionDeadline || (hackathon as HackathonDraft).data.timeline - ?.winnerAnnouncementDate + ?.winnersAnnouncedAt || + (hackathon as HackathonDraft).data.timeline + ?.winnerAnnouncementDate || + (hackathon as HackathonDraft).data.timeline?.judgingEnd || + (hackathon as HackathonDraft).data.timeline?.judgingDate || + (hackathon as HackathonDraft).data.timeline?.judgingStart : (hackathon as Hackathon).submissionDeadline || (hackathon as Hackathon).endDate; const totalPrize = isDraft diff --git a/app/(landing)/organizations/layout.tsx b/app/(landing)/organizations/layout.tsx index f187e7e5..4cf71c88 100644 --- a/app/(landing)/organizations/layout.tsx +++ b/app/(landing)/organizations/layout.tsx @@ -10,9 +10,11 @@ import { NavigationLoadingProvider, useNavigationLoading, } from '@/lib/providers'; +import { cn } from '@/lib/utils'; import NewHackathonSidebar from '@/components/organization/hackathons/new/NewHackathonSidebar'; import HackathonSidebar from '@/components/organization/hackathons/details/HackathonSidebar'; import HackathonNavigationLoader from '@/components/organization/hackathons/details/HackathonNavigationLoader'; + export default function OrganizationsLayout({ children, }: { @@ -92,7 +94,14 @@ function OrganizationsLayoutContent({ )} {/* {showNewGrantSidebar && } */} -
{children}
+
+ {children} +
) : (
{children}
diff --git a/app/(landing)/projects/[slug]/page.tsx b/app/(landing)/projects/[slug]/page.tsx index 2ba0f1d2..db8c82ab 100644 --- a/app/(landing)/projects/[slug]/page.tsx +++ b/app/(landing)/projects/[slug]/page.tsx @@ -126,7 +126,7 @@ function ProjectContent({
diff --git a/app/me/crowdfunding/[slug]/edit/components/ContactSocialSection.tsx b/app/me/crowdfunding/[slug]/edit/components/ContactSocialSection.tsx index b0cb46a8..08ac8621 100644 --- a/app/me/crowdfunding/[slug]/edit/components/ContactSocialSection.tsx +++ b/app/me/crowdfunding/[slug]/edit/components/ContactSocialSection.tsx @@ -69,8 +69,8 @@ export function ContactSocialSection({

Your Project contact information will be used for Project - verification and for Boundless staff to contact you. The contact - information can only be accessed by Boundless staff. + verification and for Boundless team to contact you. The contact + information can only be accessed by Boundless team.

diff --git a/app/sitemap.ts b/app/sitemap.ts index 7189496d..e5db2450 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -3,7 +3,7 @@ import { getBlogPosts } from '@/lib/api/blog'; import { getHackathons } from '@/lib/api/hackathons'; import { getCrowdfundingProjects } from '@/features/projects/api'; import type { BlogPost } from '@/types/blog'; -import type { Hackathon } from '@/types/hackathon/core'; +import type { Hackathon as HackathonAPI } from '@/lib/api/hackathons'; import type { Crowdfunding } from '@/features/projects/types'; // Constants @@ -173,14 +173,14 @@ async function fetchHackathonsSitemap(): Promise { } return response.data.hackathons - .filter((hackathon: Hackathon) => { + .filter((hackathon: HackathonAPI) => { // Validate required fields if (!hackathon.slug) { return false; } return true; }) - .map((hackathon: Hackathon) => ({ + .map((hackathon: HackathonAPI) => ({ url: `${SITE_URL}/hackathons/${hackathon.slug}`, lastModified: new Date( hackathon.updatedAt || hackathon.publishedAt || new Date() diff --git a/components/connect-wallet/index copy.tsx b/components/connect-wallet/index copy.tsx deleted file mode 100644 index fc20dc23..00000000 --- a/components/connect-wallet/index copy.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from '../ui/dialog'; -import WalletCard from './wallet-card'; -import { CircleQuestionMark, X, Loader2, AlertCircle } from 'lucide-react'; -import Link from 'next/link'; -import { Checkbox } from '../ui/checkbox'; -import { ScrollArea } from '../ui/scroll-area'; -import { Button } from '../ui/button'; -import { useWalletStore } from '@/hooks/use-wallet'; -import { toast } from 'sonner'; -import { TooltipContent, TooltipProvider } from '@radix-ui/react-tooltip'; -import { Tooltip, TooltipTrigger } from '../ui/tooltip'; - -const ConnectWallet = ({ - open, - onOpenChange, - onConnect, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onConnect?: () => void; -}) => { - const [selectedNetwork, setSelectedNetwork] = useState('testnet'); - const [acceptedTerms, setAcceptedTerms] = useState(true); - const [isConnecting, setIsConnecting] = useState(false); - const [connectingWallet, setConnectingWallet] = useState(null); - - const { - network, - availableWallets, - isConnected, - isLoading, - error, - initializeWalletKit, - connectWallet, - clearError, - } = useWalletStore(); - - // Initialize wallet kit when modal opens - useEffect(() => { - if (open && !isConnected) { - initializeWalletKit(selectedNetwork as 'testnet' | 'public').catch(() => { - // Silently handle initialization errors - // These are expected when wallet is not available - }); - } - }, [open, isConnected, initializeWalletKit, selectedNetwork]); - - // Handle network selection - useEffect(() => { - setSelectedNetwork(network); - }, [network]); - - const networks = [ - { - id: 'testnet', - name: 'Testnet', - icon: '/globe.svg', - active: true, - }, - { - id: 'public', - name: 'Public', - icon: '/globe.svg', - active: true, - }, - ]; - - const handleWalletSelect = async (walletId: string) => { - if (!acceptedTerms) { - toast.error('Please accept the terms and conditions first'); - return; - } - - setIsConnecting(true); - setConnectingWallet(walletId); - clearError(); - - try { - // Show specific instructions for different wallets - const walletInstructions = { - freighter: 'Please unlock Freighter and approve the connection', - albedo: - 'Albedo will open in a new window. Please approve the connection', - rabet: 'Please unlock Rabet and approve the connection', - xbull: 'Please unlock xBull and approve the connection', - lobstr: 'Please unlock Lobstr and approve the connection', - hana: 'Please unlock Hana and approve the connection', - 'hot-wallet': - 'Please unlock your hardware wallet and approve the connection', - }; - - const instruction = - walletInstructions[walletId as keyof typeof walletInstructions] || - 'Please approve the connection'; - - toast.info(instruction, { - duration: 5000, - }); - - await connectWallet(walletId); - - toast.success('Wallet connected successfully!', { - description: `Connected to ${network === 'testnet' ? 'Testnet' : 'Public'} network`, - }); - onOpenChange(false); - onConnect?.(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to connect wallet'; - - // Provide specific error messages for different wallets - let specificError = errorMessage; - if (errorMessage.includes('not available')) { - specificError = `${walletId} is not installed or not available. Please install it first.`; - } else if (errorMessage.includes('permission')) { - specificError = `Permission denied. Please unlock ${walletId} and try again.`; - } else if (errorMessage.includes('network')) { - specificError = `Network mismatch. Please switch ${walletId} to ${selectedNetwork} network.`; - } - - toast.error('Connection failed', { - description: specificError, - duration: 8000, - }); - } finally { - setIsConnecting(false); - setConnectingWallet(null); - } - }; - - const handleNetworkChange = async (networkId: string) => { - if (networkId === selectedNetwork) return; - - setSelectedNetwork(networkId); - - // Reinitialize wallet kit with new network - try { - await initializeWalletKit(networkId as 'testnet' | 'public'); - toast.success( - `Switched to ${networkId === 'testnet' ? 'Testnet' : 'Public'} network` - ); - } catch { - toast.error('Failed to switch network'); - // Log error for debugging but don't expose to user - } - }; - - // Filter available wallets and map them to our UI format - const walletOptions = availableWallets - .filter((wallet: { isAvailable: boolean }) => wallet.isAvailable) - .map((wallet: { id: string; name: string; icon: string }) => ({ - id: wallet.id, - name: wallet.name, - icon: wallet.icon, - disabled: false, - })); - - // Fallback wallets if none are available - const fallbackWallets = [ - { - id: 'freighter', - name: 'Freighter', - icon: '/wallets/freighter.svg', - disabled: false, - }, - { - id: 'albedo', - name: 'Albedo', - icon: '/wallets/albedo.svg', - disabled: false, - }, - { - id: 'rabet', - name: 'Rabet', - icon: '/wallets/rabet.svg', - disabled: false, - }, - { - id: 'xbull', - name: 'xBull', - icon: '/wallets/xbull.svg', - disabled: false, - }, - { - id: 'lobstr', - name: 'Lobstr', - icon: '/wallets/lobstr.svg', - disabled: false, - }, - { - id: 'hana', - name: 'Hana', - icon: '/wallets/hana.svg', - disabled: false, - }, - { - id: 'hot-wallet', - name: 'HOT Wallet', - icon: '/wallets/hot-wallet.svg', - disabled: false, - }, - ]; - - const wallets = walletOptions.length > 0 ? walletOptions : fallbackWallets; - - return ( - - - {/* Header */} -
-
- - Connect Wallet - - - - - - - - -

- What is a Wallet? -

-

- Wallets are used to send, receive, and store the keys you - use to sign blockchain transactions. -

-

- What is a Stellar Blockchain? -

-

- Stellar is a decentralized network that allows you to send - and receive digital assets. -

-
-
-
-
- -
- - {/* Description and Terms */} - -

- Select what network and wallet below -

- -
-

- Accept{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -

-
- - setAcceptedTerms(checked as boolean) - } - className='data-[state=checked]:border-[#A7F950] data-[state=checked]:bg-[#A7F950]' - id='terms-checkbox' - /> - -
-
-
- - {/* Network Selection */} -
-

Choose Network

-
- {networks.map(network => ( - - ))} -
-
- - {/* Wallet Selection */} -
-
-

Choose Wallet

- {isLoading && ( -
- - Loading wallets... -
- )} -
- - {/* Connection Status */} - {isConnecting && connectingWallet && ( -
- - - Connecting to {connectingWallet}... - -
- )} - - -
- {wallets.map( - (wallet: { - id: string; - name: string; - icon: string; - disabled?: boolean; - }) => ( - handleWalletSelect(wallet.id)} - icon={wallet.icon} - label={wallet.name} - /> - ) - )} -
-
-
- - {/* Error Display */} - {error && ( -
-
- - - Connection Error - -
-

{error}

-
- )} -
-
- ); -}; - -export default ConnectWallet; diff --git a/components/connect-wallet/index.tsx b/components/connect-wallet/index.tsx index a8b18c45..f14b298b 100644 --- a/components/connect-wallet/index.tsx +++ b/components/connect-wallet/index.tsx @@ -13,6 +13,8 @@ import { useWalletStore } from '@/hooks/use-wallet'; import { toast } from 'sonner'; import { AlertCircle, Loader2, X } from 'lucide-react'; +import { getCurrentNetwork } from '@/lib/wallet-utils'; + const ConnectWallet = ({ open, onOpenChange, @@ -22,7 +24,7 @@ const ConnectWallet = ({ onOpenChange: (open: boolean) => void; onConnect?: () => void; }) => { - const [selectedNetwork, setSelectedNetwork] = useState('testnet'); + const [selectedNetwork, setSelectedNetwork] = useState(getCurrentNetwork()); const [isConnecting, setIsConnecting] = useState(false); const [connectingWallet, setConnectingWallet] = useState(null); diff --git a/components/hackathons/HackathonsPage.tsx b/components/hackathons/HackathonsPage.tsx index 9259882f..d01f7451 100644 --- a/components/hackathons/HackathonsPage.tsx +++ b/components/hackathons/HackathonsPage.tsx @@ -125,7 +125,7 @@ export default function HackathonsPage({
('newest'); + + const sortedAnnouncements = useMemo(() => { + return [...announcements].sort((a, b) => { + // Always put pinned at top + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + + // Then sort by date + const dateA = new Date(a.publishedAt || a.createdAt).getTime(); + const dateB = new Date(b.publishedAt || b.createdAt).getTime(); + + return sortBy === 'newest' ? dateB - dateA : dateA - dateB; + }); + }, [announcements, sortBy]); + + const isRecent = (date: string) => { + const announcementDate = new Date(date).getTime(); + const now = new Date().getTime(); + const diff = now - announcementDate; + return diff < 1000 * 60 * 60 * 24; // 24 hours + }; + + return ( +
+
+
+ +

Announcements

+ + {announcements.length} + +
+ + +
+ +
+ {sortedAnnouncements.map(announcement => ( + + {isRecent(announcement.publishedAt || announcement.createdAt) && ( +
+ New +
+ )} + +
+
+
+ {announcement.isPinned && ( + + )} +

+ {announcement.title} +

+
+ +

+ {announcement.content.replace(/<[^>]*>/g, '')} +

+ +
+ + {new Date( + announcement.publishedAt || announcement.createdAt + ).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + By {announcement.author?.name || 'Organizer'} +
+
+ +
+ +
+
+ + ))} +
+
+ ); +} diff --git a/components/hackathons/hackathonBanner.tsx b/components/hackathons/hackathonBanner.tsx index 5554031d..c22fc6e0 100644 --- a/components/hackathons/hackathonBanner.tsx +++ b/components/hackathons/hackathonBanner.tsx @@ -1,14 +1,8 @@ 'use client'; import { Button } from '@/components/ui/button'; import { useMemo } from 'react'; -import { - FileText, - Users, - ArrowRight, - Calendar, - Clock, - Trophy, -} from 'lucide-react'; +import { FileText, Users, ArrowRight, Calendar, Trophy } from 'lucide-react'; +import { CountdownTimer } from '@/components/ui/timer'; import { useAuthStatus } from '@/hooks/use-auth'; import { useRouter, usePathname } from 'next/navigation'; import Image from 'next/image'; @@ -40,6 +34,7 @@ interface HackathonBannerProps { onFindTeamClick?: () => void; onLeaveClick?: () => void; isLeaving?: boolean; + participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; } export function HackathonBanner({ @@ -56,6 +51,7 @@ export function HackathonBanner({ registrationDeadlinePolicy, registrationDeadline, isLeaving, + participantType, onJoinClick, onSubmitClick, onViewSubmissionClick, @@ -259,6 +255,16 @@ export function HackathonBanner({ {getStatusText()} + {participantType && ( + <> +
+ + {participantType === 'TEAM_OR_INDIVIDUAL' + ? 'Hybrid' + : participantType.toLowerCase()} + + + )}
{/* Title & Tagline */} @@ -290,17 +296,20 @@ export function HackathonBanner({ {/* Stats Row */}
{/* Countdown Timer */} - {timeRemaining.total > 0 && ( + {(status === 'ongoing' || status === 'upcoming') && (
- {status === 'ongoing' ? 'Ends In' : 'Starts In'}
-
- {formatCountdown(timeRemaining)} -
+
)} diff --git a/components/hackathons/hackathonNavTabs.tsx b/components/hackathons/hackathonNavTabs.tsx index 40dd2a3c..6fa882a5 100644 --- a/components/hackathons/hackathonNavTabs.tsx +++ b/components/hackathons/hackathonNavTabs.tsx @@ -1,4 +1,4 @@ -'use client'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; interface HackathonNavTab { id: string; @@ -24,30 +24,33 @@ export function HackathonNavTabs({ return (
-
- {tabs.map(tab => { - const isActive = activeTab === tab.id; - return ( - - ); - })} -
+ + ); + })} +
+ +
); diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx index 3ce1adfc..b152ca14 100644 --- a/components/hackathons/hackathonStickyCard.tsx +++ b/components/hackathons/hackathonStickyCard.tsx @@ -37,6 +37,7 @@ interface HackathonStickyCardProps { onViewSubmissionClick?: () => void; onFindTeamClick?: () => void; onLeaveClick?: () => void; + participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; } export function HackathonStickyCard(props: HackathonStickyCardProps) { @@ -57,6 +58,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { onFindTeamClick, onLeaveClick, isLeaving, + participantType, } = props; const { status } = useHackathonStatus(startDate, deadline); @@ -173,6 +175,23 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
)} + {/* Participation Type Section */} + {participantType && ( +
+
+ + + Participation + +
+
+ {participantType === 'TEAM_OR_INDIVIDUAL' + ? 'Hybrid' + : participantType.toLowerCase()} +
+
+ )} + {/* Countdown Timer */} {/* {timeRemaining.total > 0 && (
diff --git a/components/hackathons/participants/profileCard.tsx b/components/hackathons/participants/profileCard.tsx index 2ba466dc..08f2b7a2 100644 --- a/components/hackathons/participants/profileCard.tsx +++ b/components/hackathons/participants/profileCard.tsx @@ -7,13 +7,29 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import type { ParticipantDisplay } from '@/lib/api/hackathons/index'; import Image from 'next/image'; -import { MessageCircle, Users, CheckCircle2, UserPlus } from 'lucide-react'; +import { + MessageCircle, + Users, + CheckCircle2, + UserPlus, + Info, +} from 'lucide-react'; import { useParticipants } from '@/hooks/hackathon/use-participants'; import Link from 'next/link'; import { useAuthStatus } from '@/hooks/use-auth'; -import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; import { InviteUserModal } from '../team-formation/InviteUserModal'; +import { useFollow } from '@/hooks/use-follow'; +import { useFollowStats } from '@/hooks/use-follow-stats'; +import { getUserProfileByUsername } from '@/lib/api/auth'; +import type { PublicUserProfile } from '@/features/projects/types'; +import { useEffect } from 'react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; const BRAND_COLOR = '#a7f950'; @@ -43,8 +59,34 @@ const formatJoinDate = (dateString: string) => { }; export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { - const [isFollowing, setIsFollowing] = useState(false); + const [profileData, setProfileData] = useState( + null + ); + const { + toggleFollow, + isFollowing, + isLoading: isFollowLoading, + } = useFollow('USER', participant.userId); + const { stats: followStats, refetch: refetchStats } = useFollowStats( + participant.userId + ); + const { participants, allParticipants, teams } = useParticipants(); + + useEffect(() => { + const fetchProfile = async () => { + try { + if (participant.username) { + const data = await getUserProfileByUsername(participant.username); + setProfileData(data); + } + } catch (error) { + console.error('Error fetching profile data:', error); + } + }; + fetchProfile(); + }, [participant.username]); + const teamMembers = useMemo(() => { if (participant.role === 'leader' && participant.teamId) { return participants.filter( @@ -86,6 +128,11 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { const currentUsername = user.username || (user.profile as any)?.username; const currentUserId = user.id || (user as any).userId; + // 0. Only allow if hackathon allows teams + if (currentHackathon.participantType === 'INDIVIDUAL') { + return false; + } + // Don't invite yourself if ( participant.id === currentUserParticipant.id || @@ -196,7 +243,11 @@ export function ProfileCard({ participant, onInviteClick }: ProfileCardProps) { {/* Action Buttons */}
- + + + +
+ +
+
+ + Messaging coming soon + +
+
{canInvite && ( + + + + + +
- {/* Bottom accent line */} -
- - ); - })} -
- - {/* Decorative bottom gradient */} -
- - + {/* Desktop Sidebar */} + + ); } diff --git a/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx b/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx index 41efe08c..c9df713c 100644 --- a/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx +++ b/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx @@ -10,6 +10,7 @@ interface ModalFooterProps { isFetchingCriteria: boolean; hasCriteria: boolean; existingScore: { scores: unknown[]; notes?: string } | null; + mode?: 'judge' | 'organizer-override'; onCancel: () => void; onSubmit: () => void; } @@ -20,9 +21,25 @@ export const ModalFooter = ({ isFetchingCriteria, hasCriteria, existingScore, + mode = 'judge', onCancel, onSubmit, }: ModalFooterProps) => { + const isOverride = mode === 'organizer-override'; + const actionLabel = isOverride + ? existingScore + ? 'Update Override' + : 'Apply Override' + : existingScore + ? 'Update Grade' + : 'Submit Grade'; + + const loadingLabel = isOverride + ? 'Applying...' + : existingScore + ? 'Updating...' + : 'Submitting...'; + return (
@@ -59,12 +76,10 @@ export const ModalFooter = ({ {isLoading ? ( <> - {existingScore ? 'Updating...' : 'Submitting...'} + {loadingLabel} - ) : existingScore ? ( - 'Update Grade' ) : ( - 'Submit Grade' + actionLabel )}
diff --git a/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx b/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx index c9450e0c..5887c88a 100644 --- a/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx +++ b/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx @@ -9,36 +9,51 @@ import type { JudgingCriterion } from '@/lib/api/hackathons'; interface ScoringSectionProps { criteria: JudgingCriterion[]; scores: Record; + comments: Record; validationErrors: Record; focusedInput: string | null; - onScoreChange: (criterionTitle: string, value: string | number) => void; - onInputFocus: (criterionTitle: string) => void; - onInputBlur: (criterionTitle: string) => void; + onScoreChange: (criterionKey: string, value: string | number) => void; + onCommentChange: (criterionKey: string, value: string) => void; + onInputFocus: (criterionKey: string) => void; + onInputBlur: (criterionKey: string) => void; onKeyDown: ( e: React.KeyboardEvent, - criterionTitle: string + criterionKey: string ) => void; getScoreColor: (percentage: number) => string; + overallComment: string; + onOverallCommentChange: (value: string) => void; + showComments?: boolean; } export const ScoringSection = ({ criteria, scores, + comments, validationErrors, focusedInput, onScoreChange, + onCommentChange, onInputFocus, onInputBlur, onKeyDown, getScoreColor, + overallComment, + onOverallCommentChange, + showComments = true, }: ScoringSectionProps) => { + const getCriterionKey = (criterion: JudgingCriterion) => { + return criterion.id || criterion.name || criterion.title; + }; + const scoredCount = criteria.filter(c => { - const score = scores[c.title]; + const key = getCriterionKey(c); + const score = scores[key]; return typeof score === 'number' && score > 0; }).length; return ( -
+

Evaluation Criteria @@ -49,39 +64,41 @@ export const ScoringSection = ({

-
+
{criteria.map(criterion => { + const key = getCriterionKey(criterion); + const criterionTitle = + criterion.title || criterion.name || 'Untitled Criterion'; const score = - typeof scores[criterion.title] === 'number' - ? (scores[criterion.title] as number) - : 0; - const hasError = validationErrors[criterion.title]; - const isFocused = focusedInput === criterion.title; - const scorePercentage = score; + typeof scores[key] === 'number' ? (scores[key] as number) : 0; + const comment = comments[key] || ''; + const hasError = validationErrors[key]; + const isFocused = focusedInput === key; + const scorePercentage = score * 10; return (
-
+
- {criterion.title} + {criterionTitle} {criterion.weight}% weight
{criterion.description && ( -

+

{criterion.description}

)} @@ -93,32 +110,38 @@ export const ScoringSection = ({ )}
-
- - onScoreChange(criterion.title, e.target.value) - } - onFocus={() => onInputFocus(criterion.title)} - onBlur={() => onInputBlur(criterion.title)} - onKeyDown={e => onKeyDown(e, criterion.title)} - className={cn( - 'w-20 rounded-lg border-1 bg-gray-950 px-3 py-2 text-center text-lg font-bold text-white transition-all', - 'focus:ring-primary/50 focus:ring-2 focus:outline-none', - isFocused ? 'border-primary' : 'border-gray-700', - hasError && 'border-error-500' - )} - placeholder='0' - /> - / 100 +
+
+
+ onScoreChange(key, e.target.value)} + onFocus={() => onInputFocus(key)} + onBlur={() => onInputBlur(key)} + onKeyDown={e => onKeyDown(e, key)} + className={cn( + 'w-20 rounded-lg border bg-gray-950 px-3 py-2 text-center text-xl font-bold text-white transition-all', + 'focus:ring-primary/50 focus:ring-2 focus:outline-none', + isFocused ? 'border-primary' : 'border-gray-700', + hasError && 'border-error-500' + )} + placeholder='0' + /> + + / 10 + +
+
{/* Progress bar */} -
+
+ + {showComments && ( +
+ +