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..530574c1 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -6,149 +6,673 @@ import JudgingParticipant from '@/components/organization/cards/JudgingParticipa 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, +} 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 [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 still empty, try Better Auth directly + if (finalMembers.length === 0) { + const { data: baData } = await authClient.organization.listMembers({ + query: { organizationId: targetOrgId, limit: 100 }, + }); - if (response.success) { - setSubmissions(response.data || []); - setPagination(response.meta.pagination); - setPage(pageNum); + 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); + console.log('Judging Results Response:', res); + if (res.success) { + setJudgingResults(res.data || []); + } else { + setJudgingResults([]); + toast.error(res.message || 'Failed to load judging results'); + } + } catch (error: any) { + console.error('Error fetching results:', error); + setJudgingResults([]); + 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) { + setWinners(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); + } + }; + + // Calculate statistics safely + const gradedCount = judgingResults.length; + const averageHackathonScore = + 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; 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 / submissions.length) * 100) : 0}% Completion`} + /> + +
- ) : ( - <> -
- {submissions.map(submission => ( - - ))} -
- - {/* Pagination */} - {pagination.totalPages > 1 && ( -
- - - Page {pagination.page} of {pagination.totalPages} - - + + { + setActiveTab(value); + if (value === 'results') { + fetchResults(); + fetchWinners(); + } + }} + className='w-full' + > + + + Overview + + + Criteria + + + Judges + + + Results + + + + + {isLoading ? ( +
+ +
+ ) : ( +
+ {submissions.map(submission => ( + 0} + judges={currentJudges} + isJudgesLoading={isRefreshingJudges} + currentUserId={currentUserId || undefined} + onSuccess={handleSuccess} + /> + ))} +
+ )} +
+ + + + + + +
+ {/* Current Judges List */} +
+

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

+
+ {currentJudges.length === 0 ? ( +

+ No judges assigned yet. +

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

+ {judge.name} +

+

+ {judge.userId} +

+
+
+ {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 && ( +

+ No organization members found. +

+ )} +
+
+ )} +
+
+ + +
+ {canPublishResults && judgingResults.length > 0 && ( +
+
+

+ Finalize Competition +

+

+ Publish the current rankings to name the winners. +

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

+ + Final Winners +

+ +
+ )} + +
+

+ Current Standings +

+ {isFetchingResults ? ( +
+ +
+ ) : ( + + )} +
- )} - - )} +
+
+
); 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/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/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/ScoringSection.tsx b/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx index c9450e0c..cb66b083 100644 --- a/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx +++ b/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx @@ -9,36 +9,49 @@ 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; } export const ScoringSection = ({ criteria, scores, + comments, validationErrors, focusedInput, onScoreChange, + onCommentChange, onInputFocus, onInputBlur, onKeyDown, getScoreColor, + overallComment, + onOverallCommentChange, }: 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 +62,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 +108,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 */} -
+
+ + {/* Comment field */} +
+ +