diff --git a/app/(landing)/hackathons/page.tsx b/app/(landing)/hackathons/page.tsx index d498e40bd..ddf1f491f 100644 --- a/app/(landing)/hackathons/page.tsx +++ b/app/(landing)/hackathons/page.tsx @@ -1,15 +1,15 @@ -import React from 'react'; import { Metadata } from 'next'; import { generatePageMetadata } from '@/lib/metadata'; +import HackathonsPageHero from '@/components/hackathons/HackathonsPageHero'; +import HackathonsPage from '@/components/hackathons/HackathonsPage'; export const metadata: Metadata = generatePageMetadata('hackathons'); -const HackathonsPage = () => { +export default function HackathonsPageRoute() { return ( -
- Hackathons Page +
+ +
); -}; - -export default HackathonsPage; +} diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index 9ec285103..e6aacc5e5 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -1,26 +1,151 @@ 'use client'; +import { useEffect, useState, useCallback } from 'react'; import MetricsCard from '@/components/organization/cards/MetricsCard'; import JudgingParticipant from '@/components/organization/cards/JudgingParticipant'; import { useParams } from 'next/navigation'; +import { + getJudgingSubmissions, + type JudgingSubmission, +} from '@/lib/api/hackathons'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; export default function JudgingPage() { const params = useParams(); - void params.id; - void params.hackathonId; + const organizationId = params.id as string; + const hackathonId = params.hackathonId as string; + + const [submissions, setSubmissions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [page, setPage] = useState(1); + const [pagination, setPagination] = useState({ + currentPage: 1, + totalPages: 1, + totalItems: 0, + itemsPerPage: 10, + hasNext: false, + hasPrev: false, + }); + + const fetchSubmissions = useCallback( + async (pageNum = 1) => { + if (!organizationId || !hackathonId) return; + + setIsLoading(true); + try { + const response = await getJudgingSubmissions( + organizationId, + hackathonId, + pageNum, + 10 + ); + + if (response.success) { + setSubmissions(response.data); + setPagination(response.pagination); + setPage(pageNum); + } + } catch { + toast.error('Failed to load submissions'); + } finally { + setIsLoading(false); + } + }, + [organizationId, hackathonId] + ); + + useEffect(() => { + fetchSubmissions(); + }, [fetchSubmissions]); + + const handleSuccess = () => { + fetchSubmissions(page); + }; + + // Calculate statistics + const totalSubmissions = pagination.totalItems; + const averageScore = + submissions.length > 0 + ? submissions.reduce((sum, sub) => { + return sum + (sub.averageScore || 0); + }, 0) / submissions.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 ? ( +
+ +
+ ) : submissions.length === 0 ? ( +
+ No shortlisted submissions found +
+ ) : ( + <> +
+ {submissions.map(submission => ( + + ))} +
+ + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+ + + Page {pagination.currentPage} of {pagination.totalPages} + + +
+ )} + + )}
); } diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx index 9a6cc87cb..ba20ae5e3 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx @@ -1,243 +1,77 @@ 'use client'; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/components/ui/chart'; import { useParams } from 'next/navigation'; -import { Line, LineChart, XAxis } from 'recharts'; -import { Check } from 'lucide-react'; +import { Loader2, AlertCircle } from 'lucide-react'; +import { useHackathons } from '@/hooks/use-hackathons'; +import { useEffect } from 'react'; +import { useHackathonAnalytics } from '@/hooks/use-hackathon-analytics'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { HackathonStatistics } from '@/components/organization/hackathons/details/HackathonStatistics'; +import { HackathonCharts } from '@/components/organization/hackathons/details/HackathonCharts'; +import { HackathonTimeline } from '@/components/organization/hackathons/details/HackathonTimeline'; export default function HackathonPage() { const params = useParams(); - void params.id; - void params.hackathonId; - const chartData = [ - { month: 'January', desktop: 186 }, - { month: 'February', desktop: 305 }, - { month: 'March', desktop: 237 }, - { month: 'April', desktop: 73 }, - { month: 'May', desktop: 209 }, - { month: 'June', desktop: 214 }, - ]; - const chartConfig = { - desktop: { - label: 'Desktop', - color: '#ffffff', - }, - } satisfies ChartConfig; + const organizationId = params.id as string; + const hackathonId = params.hackathonId as string; + + const { currentHackathon, currentLoading, currentError, fetchHackathon } = + useHackathons({ + organizationId, + autoFetch: false, + }); + + const { statistics, statisticsLoading, timeSeriesData, timeSeriesLoading } = + useHackathonAnalytics(organizationId, hackathonId); + + useEffect(() => { + if (organizationId && hackathonId) { + void fetchHackathon(hackathonId); + } + }, [organizationId, hackathonId, fetchHackathon]); + + if (currentLoading) { + return ( +
+
+ +

Loading hackathon data...

+
+
+ ); + } + + if (currentError || !currentHackathon) { + return ( +
+ + + Error + + {currentError || + 'Failed to load hackathon. Please try again later.'} + + +
+ ); + } return (
-
-
- Participants - 100 -
-
- Submissions - 100 -
-
- Submissions - 100 -
-
- Submissions - 100 -
-
-
-
-
- Submissions over time -
- - - value.slice(0, 3)} - tick={{ fontSize: 10 }} - /> - } - /> - - - -
-
-
- Participants sign-ups trend -
- - - value.slice(0, 3)} - tick={{ fontSize: 10 }} - /> - } - /> - - - -
-
+ +
- {/* Timeline Section */}

Timeline

- -
- {/* Timeline Items */} -
- {/* Winner Announcement - Active */} -
-
-
-
-
-
-
-
-
-
-
-

- Winner Announcement -

-

- Final results published and prizes distributed to winners. -

-
-
- 20 Aug, 2025 -
-
-
- - {/* Judging - Completed */} -
-
-
- -
-
-
-
-
-
-
-

- Judging -

-

- Judges are currently reviewing and scoring all submitted - projects. -

-
-
- 20 Aug, 2025 -
-
-
- - {/* Submission - Completed */} -
-
-
- -
-
-
-
-
-
-
-

- Submission -

-

- Participants are submitting their projects for review before - the deadline. -

-
-
- 20 Aug, 2025 -
-
-
- - {/* Register - Completed */} -
-
-
- -
-
-
-
-

- Register -

-

- Individuals and teams are signing up to participate in the - hackathon. -

-
-
- 20 Aug, 2025 -
-
-
-
-
+
); diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index a61607f6e..fc58acc93 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -1,25 +1,188 @@ 'use client'; +import { useEffect, useMemo, useRef } from 'react'; import MetricsCard from '@/components/organization/cards/MetricsCard'; import Participant from '@/components/organization/cards/Participant'; import { useParams } from 'next/navigation'; +import { useHackathons } from '@/hooks/use-hackathons'; +import { getHackathonStatistics } from '@/lib/api/hackathons'; +import { useState } from 'react'; export default function ParticipantsPage() { const params = useParams(); - void params.id; - void params.hackathonId; + const organizationId = params.id as string; + const hackathonId = params.hackathonId as string; + + const { + participants, + participantsLoading, + participantsError, + fetchParticipants, + } = useHackathons({ + organizationId, + autoFetch: false, + }); + + // Handler to refresh participants after review actions + const handleReviewSuccess = () => { + if (organizationId && hackathonId) { + fetchParticipants(hackathonId); + } + }; + + const [statistics, setStatistics] = useState<{ + participantsCount: number; + submissionsCount: number; + } | null>(null); + const [statisticsLoading, setStatisticsLoading] = useState(false); + + // Refs to prevent duplicate fetches + const hasFetchedParticipantsRef = useRef(false); + const hasFetchedStatisticsRef = useRef(false); + const lastOrgIdRef = useRef(null); + const lastHackathonIdRef = useRef(null); + + // Reset fetch flags when IDs change + useEffect(() => { + if ( + lastOrgIdRef.current !== organizationId || + lastHackathonIdRef.current !== hackathonId + ) { + // IDs changed, reset fetch flags + hasFetchedParticipantsRef.current = false; + hasFetchedStatisticsRef.current = false; + lastOrgIdRef.current = organizationId; + lastHackathonIdRef.current = hackathonId; + } + }, [organizationId, hackathonId]); + + // Fetch participants on mount or when IDs change + useEffect(() => { + if (organizationId && hackathonId && !hasFetchedParticipantsRef.current) { + hasFetchedParticipantsRef.current = true; + fetchParticipants(hackathonId); + } + }, [organizationId, hackathonId, fetchParticipants]); + + // Fetch statistics only once on mount or when IDs change + useEffect(() => { + const loadStatistics = async () => { + if (!organizationId || !hackathonId || hasFetchedStatisticsRef.current) { + return; + } + + hasFetchedStatisticsRef.current = true; + setStatisticsLoading(true); + try { + const response = await getHackathonStatistics( + organizationId, + hackathonId + ); + setStatistics({ + participantsCount: response.data.participantsCount, + submissionsCount: response.data.submissionsCount, + }); + } catch { + // Fallback to calculating from participants data only if we have it + // Don't trigger another fetch + } finally { + setStatisticsLoading(false); + } + }; + + if (organizationId && hackathonId) { + loadStatistics(); + } + }, [organizationId, hackathonId]); + + // Ensure participants is always an array + const participantsList = useMemo(() => { + return Array.isArray(participants) ? participants : []; + }, [participants]); + + // Calculate metrics from participants if statistics not available + // This is a fallback calculation, not a trigger for fetching + const metrics = useMemo(() => { + if (statistics) { + return statistics; + } + + // Only calculate from participants if we have them and statistics failed + const participantsCount = participantsList.length; + const submissionsCount = participantsList.filter(p => p.submission).length; + + return { + participantsCount, + submissionsCount, + }; + }, [statistics, participantsList]); + + if (participantsLoading && participantsList.length === 0) { + return ( +
+
+
Loading participants...
+
+
+ ); + } + + if (participantsError && participantsList.length === 0) { + return ( +
+
+
+ Error loading participants +
+
{participantsError}
+
+
+ ); + } return (
- - + 0 + ? `${metrics.participantsCount} registered` + : undefined + } + showTrend={true} + /> + 0 + ? `${((metrics.submissionsCount / metrics.participantsCount) * 100).toFixed(1)}% submission rate` + : undefined + } + />
- - - - + {participantsList.length === 0 ? ( +
+ No participants found +
+ ) : ( + participantsList.map(participant => ( + + )) + )}
); diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx index be8d3d1fb..010e60d41 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx @@ -1,232 +1,109 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { useParams } from 'next/navigation'; -import { Megaphone } from 'lucide-react'; -import PodiumSection from '@/components/organization/hackathons/rewards/PodiumSection'; -import SubmissionsList from '@/components/organization/hackathons/rewards/SubmissionsList'; -import AnnounceWinnersModal from '@/components/organization/hackathons/rewards/AnnounceWinnersModal'; -import WinnersPreviewPage from '@/components/organization/hackathons/rewards/WinnersPreviewPage'; -import { Submission } from '@/components/organization/hackathons/rewards/types'; -import { BoundlessButton } from '@/components/buttons'; -import { api } from '@/lib/api/api'; -import { toast } from 'sonner'; +import { Loader2, AlertCircle } from 'lucide-react'; +import PublishWinnersWizard from '@/components/organization/hackathons/rewards/PublishWinnersWizard'; +import { RewardsPageHeader } from '@/components/organization/hackathons/rewards/RewardsPageHeader'; +import { RewardsPageContent } from '@/components/organization/hackathons/rewards/RewardsPageContent'; +import { useHackathonRewards } from '@/hooks/use-hackathon-rewards'; +import { useRankAssignment } from '@/hooks/use-rank-assignment'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; export default function RewardsPage() { const params = useParams(); const organizationId = params.id as string; const hackathonId = params.hackathonId as string; - // Mock data - replace with API call - const [submissions, setSubmissions] = useState([ - { - id: '1', - name: 'Team Alpha', - projectName: 'Binance', - avatar: 'https://github.com/shadcn.png', - score: 21, - maxScore: 25, - rank: 1, - submissionTitle: 'Blockchain Health Platform', - }, - { - id: '2', - name: 'Collins Odumeje', - projectName: 'Bitmed', - avatar: 'https://github.com/shadcn.png', - score: 21, - maxScore: 25, - rank: 2, - submissionTitle: 'DeFi Trading Bot', - }, - { - id: '3', - name: 'Team Beta', - projectName: 'Figma', - avatar: 'https://github.com/shadcn.png', - score: 15, - maxScore: 25, - rank: 3, - submissionTitle: 'Design System Platform', - }, - { - id: '4', - name: 'John Doe', - projectName: 'Project X', - avatar: 'https://github.com/shadcn.png', - score: 18, - maxScore: 25, - submissionTitle: 'AI Assistant Tool', - }, - { - id: '5', - name: 'Jane Smith', - projectName: 'Project Y', - avatar: 'https://github.com/shadcn.png', - score: 17, - maxScore: 25, - submissionTitle: 'Blockchain Wallet', - }, - { - id: '6', - name: 'Alice Johnson', - projectName: 'Project Z', - avatar: 'https://github.com/shadcn.png', - score: 16, - maxScore: 25, - submissionTitle: 'NFT Marketplace', - }, - { - id: '7', - name: 'Bob Williams', - projectName: 'Project A', - avatar: 'https://github.com/shadcn.png', - score: 15, - maxScore: 25, - submissionTitle: 'DeFi Protocol', - }, - { - id: '8', - name: 'Charlie Brown', - projectName: 'Project B', - avatar: 'https://github.com/shadcn.png', - score: 14, - maxScore: 25, - submissionTitle: 'Web3 Social Network', - }, - { - id: '9', - name: 'Diana Prince', - projectName: 'Project C', - avatar: 'https://github.com/shadcn.png', - score: 11, - maxScore: 25, - submissionTitle: 'Crypto Exchange', - }, - ]); + const { + submissions, + setSubmissions, + escrow, + prizeTiers, + isLoading, + isLoadingEscrow, + isLoadingSubmissions, + error, + refreshEscrow, + } = useHackathonRewards(organizationId, hackathonId); - const [isAnnounceModalOpen, setIsAnnounceModalOpen] = useState(false); - const [announcement, setAnnouncement] = useState(''); - const [showPreview, setShowPreview] = useState(false); + const { handleRankChange } = useRankAssignment(); + const [isPublishWizardOpen, setIsPublishWizardOpen] = useState(false); - // Mock prize tiers - replace with API call - // This should come from the hackathon's reward configuration - const prizeTiers = [ - { rank: 1, prizeAmount: '10000', currency: 'USDC' }, - { rank: 2, prizeAmount: '5000', currency: 'USDC' }, - { rank: 3, prizeAmount: '8000', currency: 'USDC' }, - ]; - - // Fetch submissions from API - useEffect(() => { - const fetchSubmissions = async () => { - try { - const response = await api.get( - `/organizations/${organizationId}/hackathons/${hackathonId}/submissions` - ); - if (response.data) { - setSubmissions(response.data); - } - } catch { - toast.error('Failed to load submissions'); - // Keep mock data as fallback - } - }; - - if (organizationId && hackathonId) { - fetchSubmissions(); - } - }, [organizationId, hackathonId]); - - const maxRank = prizeTiers.length; - const winners = submissions.filter(s => s.rank && s.rank <= maxRank); + const maxRank = useMemo(() => prizeTiers.length, [prizeTiers.length]); + const winners = useMemo( + () => submissions.filter(s => s.rank && s.rank <= maxRank), + [submissions, maxRank] + ); const hasWinners = winners.length > 0; - const handleRankChange = (submissionId: string, newRank: number | null) => { - setSubmissions(prev => - prev.map(sub => { - if (sub.id === submissionId) { - return { ...sub, rank: newRank || undefined }; - } - // Remove rank from other submission if assigning to a winner rank - if ( - newRank && - newRank <= maxRank && - sub.rank === newRank && - sub.id !== submissionId - ) { - return { ...sub, rank: undefined }; - } - return sub; - }) + const handleRankChangeWrapper = async ( + submissionId: string, + newRank: number | null + ) => { + await handleRankChange( + submissions, + setSubmissions, + submissionId, + newRank, + maxRank, + organizationId, + hackathonId ); }; - const handleAnnounceContinue = (announcementText: string) => { - setAnnouncement(announcementText); - setShowPreview(true); - }; - - const handlePublish = async () => { - try { - await api.post( - `/organizations/${organizationId}/hackathons/${hackathonId}/winners/announce`, - { - winners: winners.map(w => ({ submissionId: w.id, rank: w.rank })), - announcement, - } - ); - toast.success('Winners announced successfully!'); - setShowPreview(false); - } catch { - toast.error('Failed to announce winners'); - } + const handlePublishSuccess = () => { + refreshEscrow(); }; - if (showPreview) { - return ( - setShowPreview(false)} - onEdit={() => { - setShowPreview(false); - setIsAnnounceModalOpen(true); - }} - onPublish={handlePublish} - /> - ); - } - return (
- {hasWinners && ( -
- setIsAnnounceModalOpen(true)} - className='gap-2' - > - - Announce Winners - + + + {isLoading && ( +
+
+ +

+ Loading rewards data... +

+

+ Please wait while we fetch the information +

+
)} - - + {!isLoading && error && ( + + + Error Loading Data + {error} + + )} - setIsPublishWizardOpen(true)} + onRankChange={handleRankChangeWrapper} + /> + )} + +
); diff --git a/components/hackathons/FeaturedHackathonsCarousel.tsx b/components/hackathons/FeaturedHackathonsCarousel.tsx new file mode 100644 index 000000000..81e8b2445 --- /dev/null +++ b/components/hackathons/FeaturedHackathonsCarousel.tsx @@ -0,0 +1,207 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'nextjs-toploader/app'; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from '@/components/ui/carousel'; +import { Button } from '@/components/ui/button'; +import { formatNumber } from '@/lib/utils'; +import type { Hackathon } from '@/lib/api/hackathons'; +import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform'; + +interface FeaturedHackathonsCarouselProps { + hackathons: Hackathon[]; + className?: string; +} + +const FeaturedHackathonsCarousel = ({ + hackathons, + className, +}: FeaturedHackathonsCarouselProps) => { + const router = useRouter(); + const [api, setApi] = useState(); + const [currentSlide, setCurrentSlide] = useState(0); + const { transformHackathonForCard } = useHackathonTransform(); + + useEffect(() => { + if (!api) return; + api.on('select', () => setCurrentSlide(api.selectedScrollSnap())); + }, [api]); + + if (!hackathons || hackathons.length === 0) { + return null; + } + + const handleHackathonClick = ( + organizationName: string, + hackathonSlug: string + ) => { + router.push(`/hackathons/${organizationName}/${hackathonSlug}`); + }; + + const getDeadlineText = (deadlineInDays: number): string => { + if (deadlineInDays <= 0) return 'Ended'; + if (deadlineInDays === 1) return 'Ends tomorrow'; + if (deadlineInDays <= 7) return `Ends in ${deadlineInDays} days`; + if (deadlineInDays <= 30) + return `Ends in ${Math.ceil(deadlineInDays / 7)} weeks`; + return `Ends in ${Math.ceil(deadlineInDays / 30)} months`; + }; + + return ( +
+
+

+ Featured Hackathons +

+

+ Discover the most exciting hackathons happening now +

+
+ +
+ + + {hackathons.map((hackathon, index) => { + const orgName = + '_organizationName' in hackathon + ? (hackathon as Hackathon & { _organizationName?: string }) + ._organizationName + : undefined; + const transformed = transformHackathonForCard(hackathon, orgName); + const deadlineText = getDeadlineText(transformed.deadlineInDays); + + return ( + +
+ {/* Banner Image */} +
+ {transformed.hackathonTitle} +
+
+
+
+ + {transformed.organizerName} + +
+
+
+ + {/* Content */} +
+
+

+ {transformed.hackathonTitle} +

+

+ {transformed.hackathonDescription} +

+
+ + {/* Stats */} +
+ {transformed.prizePool && ( +
+ Prize Pool: + + {formatNumber(transformed.prizePool.total)}{' '} + {transformed.prizePool.currency} + +
+ )} + {transformed.location && ( +
+ 📍 + + {transformed.location} + +
+ )} +
+ + {/* Deadline */} +
+ + {deadlineText} + + +
+
+
+ + ); + })} + + + {/* Navigation */} +
+ + +
+ {hackathons.map((_, i) => ( +
+ + +
+ +
+
+ ); +}; + +export default FeaturedHackathonsCarousel; diff --git a/components/hackathons/HackathonsFiltersHeader.tsx b/components/hackathons/HackathonsFiltersHeader.tsx new file mode 100644 index 000000000..f19bec93f --- /dev/null +++ b/components/hackathons/HackathonsFiltersHeader.tsx @@ -0,0 +1,127 @@ +'use client'; + +import React, { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { DesktopFilters } from './filters/DesktopFilters'; +import { MobileFilters } from './filters/MobileFilters'; +import { + sortOptions, + statusOptions, + categoryOptions, + locationOptions, +} from './filters/filter-options'; + +interface HackathonsFiltersHeaderProps { + onSearch?: (searchTerm: string) => void; + onSortChange?: (sortType: string) => void; + onStatusChange?: (status: string) => void; + onCategoryChange?: (category: string) => void; + onLocationChange?: (location: string) => void; + totalCount?: number; + className?: string; + searchPlaceholder?: string; +} + +const HackathonsFiltersHeader = ({ + onSearch, + onSortChange, + onStatusChange, + onCategoryChange, + onLocationChange, + totalCount, + className, + searchPlaceholder = 'Search hackathons...', +}: HackathonsFiltersHeaderProps) => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedSort, setSelectedSort] = useState('Newest First'); + const [selectedStatus, setSelectedStatus] = useState('All Status'); + const [selectedCategory, setSelectedCategory] = useState('All Categories'); + const [selectedLocation, setSelectedLocation] = useState('All Locations'); + + const handleSearch = (value: string) => { + setSearchTerm(value); + if (onSearch) onSearch(value); + }; + + const handleSort = (sortValue: string) => { + const option = sortOptions.find(opt => opt.value === sortValue); + setSelectedSort(option?.label || 'Newest First'); + if (onSortChange) onSortChange(sortValue); + }; + + const handleStatus = (statusValue: string) => { + const option = statusOptions.find(opt => opt.value === statusValue); + setSelectedStatus(option?.label || 'All Status'); + if (onStatusChange) onStatusChange(statusValue); + }; + + const handleCategory = (categoryValue: string) => { + const option = categoryOptions.find(opt => opt.value === categoryValue); + setSelectedCategory(option?.label || 'All Categories'); + if (onCategoryChange) onCategoryChange(categoryValue); + }; + + const handleLocation = (locationValue: string) => { + const option = locationOptions.find(opt => opt.value === locationValue); + setSelectedLocation(option?.label || 'All Locations'); + if (onLocationChange) onLocationChange(locationValue); + }; + + return ( +
+
+
+

+ Explore Hackathons +

+ {totalCount !== undefined && ( +
+ {totalCount} hackathon{totalCount !== 1 ? 's' : ''} available +
+ )} +
+ +
+ {totalCount !== undefined && ( +
+ {totalCount} hackathon{totalCount !== 1 ? 's' : ''} available +
+ )} +
+ + + + +
+
+ ); +}; + +export default HackathonsFiltersHeader; diff --git a/components/hackathons/HackathonsList.tsx b/components/hackathons/HackathonsList.tsx new file mode 100644 index 000000000..e2f6c9869 --- /dev/null +++ b/components/hackathons/HackathonsList.tsx @@ -0,0 +1,44 @@ +'use client'; + +import React from 'react'; +import HackathonCard from '@/components/landing-page/hackathon/HackathonCard'; +import type { Hackathon } from '@/lib/api/hackathons'; +import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform'; + +interface HackathonsListProps { + hackathons: Hackathon[]; + className?: string; +} + +const HackathonsList = ({ hackathons, className }: HackathonsListProps) => { + const { transformHackathonForCard } = useHackathonTransform(); + + if (hackathons.length === 0) { + return null; + } + + return ( +
+
+ {hackathons.map(hackathon => { + const orgName = + '_organizationName' in hackathon + ? (hackathon as Hackathon & { _organizationName?: string }) + ._organizationName + : undefined; + const transformed = transformHackathonForCard(hackathon, orgName); + return ( + + ); + })} +
+
+ ); +}; + +export default HackathonsList; diff --git a/components/hackathons/HackathonsPage.tsx b/components/hackathons/HackathonsPage.tsx new file mode 100644 index 000000000..c8ca8c4a2 --- /dev/null +++ b/components/hackathons/HackathonsPage.tsx @@ -0,0 +1,197 @@ +'use client'; + +import React from 'react'; +import HackathonCard from '@/components/landing-page/hackathon/HackathonCard'; +import HackathonsFiltersHeader from '@/components/hackathons/HackathonsFiltersHeader'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { useHackathonFilters } from '@/hooks/hackathon/use-hackathon-filters'; +import { useHackathonsList } from '@/hooks/hackathon/use-hackathons-list'; +import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform'; +import type { Hackathon } from '@/lib/api/hackathons'; +import { BoundlessButton } from '../buttons'; +import { ArrowDownIcon, RefreshCwIcon, XIcon } from 'lucide-react'; +import EmptyState from '../EmptyState'; +import LoadingScreen from '../landing-page/project/CreateProjectModal/LoadingScreen'; + +interface HackathonsPageProps { + className?: string; +} + +export default function HackathonsPage({ + className, +}: HackathonsPageProps = {}) { + const { + filters, + handleSearch, + handleSort, + handleStatus, + handleCategory, + handleLocation, + clearSearch, + clearAllFilters, + } = useHackathonFilters(); + + const { + hackathons, + loading, + loadingMore, + error, + hasMore, + totalCount, + loadMore, + refetch, + } = useHackathonsList({ initialFilters: filters }); + + const { transformHackathonForCard } = useHackathonTransform(); + + const hackathonCards = React.useMemo(() => { + return hackathons.map(hackathon => { + const orgName = + '_organizationName' in hackathon + ? (hackathon as Hackathon & { _organizationName?: string }) + ._organizationName + : undefined; + const cardData = transformHackathonForCard(hackathon, orgName); + return ( + + ); + }); + }, [hackathons, transformHackathonForCard]); + + return ( +
+ + +
+ {!loading && !error && hackathons.length > 0 && ( +
+
+ + Showing {hackathons.length} hackathon + {hackathons.length !== 1 ? 's' : ''} + {filters.search && ` for "${filters.search}"`} + {filters.category && ` in ${filters.category}`} + {filters.status && ` with status ${filters.status}`} + {filters.location && ` in ${filters.location}`} + +
+ {filters.search && ( + } + iconPosition='right' + > + Clear search + + )} +
+ )} + + {loading && ( +
+ +
+ )} + + {error && ( +
+
+ } + iconPosition='right' + > + Try Again + + } + /> +
+
+ )} + + {!loading && !error && hackathons.length === 0 && ( +
+
+ + + {(filters.search || + filters.category || + filters.status || + filters.location) && ( +
+ + Clear all filters + +
+ )} +
+
+ )} + + {!loading && !error && hackathons.length > 0 && ( + <> +
+ {hackathonCards} +
+ + {hasMore && ( +
+ + ) + } + className='flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:bg-gray-600' + iconPosition='right' + > + {loadingMore && ( + + )} + {loadingMore + ? 'Loading more hackathons...' + : 'Load More Hackathons'} + +
+ )} + + )} +
+
+ ); +} diff --git a/components/hackathons/HackathonsPageHero.tsx b/components/hackathons/HackathonsPageHero.tsx new file mode 100644 index 000000000..880692390 --- /dev/null +++ b/components/hackathons/HackathonsPageHero.tsx @@ -0,0 +1,89 @@ +'use client'; +import React from 'react'; +import { ArrowDown } from 'lucide-react'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import { useRouter } from 'nextjs-toploader/app'; + +export default function HackathonsPageHero() { + const router = useRouter(); + + const scrollToHackathons = () => { + const hackathonsSection = document.getElementById('explore-hackathons'); + if (hackathonsSection) { + const targetPosition = hackathonsSection.offsetTop - 100; + const startPosition = window.pageYOffset; + const distance = targetPosition - startPosition; + const duration = 1000; + let start: number | null = null; + + const animation = (currentTime: number) => { + if (start === null) start = currentTime; + const timeElapsed = currentTime - start; + const run = easeInOutQuad( + timeElapsed, + startPosition, + distance, + duration + ); + window.scrollTo(0, run); + if (timeElapsed < duration) requestAnimationFrame(animation); + }; + + const easeInOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d / 2; + if (t < 1) return (c / 2) * t * t + b; + t--; + return (-c / 2) * (t * (t - 2) - 1) + b; + }; + + requestAnimationFrame(animation); + } + }; + + const handleCreateHackathon = () => { + router.push('/hackathons/create'); + }; + + return ( +
+
+
+
+

+ + Discover hackathons + {' '} + that are shaping the future on Stellar +

+ +

+ Join innovative hackathons, compete for prizes, and build the next + generation of blockchain solutions. +

+
+
+ + + Start Exploring Hackathons + + + + + + Create Hackathon + +
+
+
+
+ ); +} diff --git a/components/hackathons/filters/DesktopFilters.tsx b/components/hackathons/filters/DesktopFilters.tsx new file mode 100644 index 000000000..e5ae36542 --- /dev/null +++ b/components/hackathons/filters/DesktopFilters.tsx @@ -0,0 +1,79 @@ +'use client'; + +import React from 'react'; +import { FilterDropdown } from './FilterDropdown'; +import { SearchInput } from './SearchInput'; +import { + sortOptions, + statusOptions, + categoryOptions, + locationOptions, +} from './filter-options'; + +interface DesktopFiltersProps { + searchTerm: string; + selectedSort: string; + selectedStatus: string; + selectedCategory: string; + selectedLocation: string; + searchPlaceholder?: string; + onSearch: (value: string) => void; + onSortChange: (value: string) => void; + onStatusChange: (value: string) => void; + onCategoryChange: (value: string) => void; + onLocationChange: (value: string) => void; +} + +export const DesktopFilters: React.FC = ({ + searchTerm, + selectedSort, + selectedStatus, + selectedCategory, + selectedLocation, + searchPlaceholder, + onSearch, + onSortChange, + onStatusChange, + onCategoryChange, + onLocationChange, +}) => { + return ( +
+
+ + + + +
+ +
+ +
+
+ ); +}; diff --git a/components/hackathons/filters/FilterDropdown.tsx b/components/hackathons/filters/FilterDropdown.tsx new file mode 100644 index 000000000..61820e71d --- /dev/null +++ b/components/hackathons/filters/FilterDropdown.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import { ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import Image from 'next/image'; +import type { FilterOption } from './filter-options'; + +interface FilterDropdownProps { + options: FilterOption[]; + selectedLabel: string; + onSelect: (value: string) => void; + showIcon?: boolean; + size?: 'default' | 'sm'; + className?: string; +} + +export const FilterDropdown: React.FC = ({ + options, + selectedLabel, + onSelect, + showIcon = false, + size = 'default', + className, +}) => { + const buttonSize = size === 'sm' ? 'sm' : undefined; + const iconSize = size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'; + const textSize = size === 'sm' ? 'text-xs' : 'text-sm'; + + return ( + + + + + + {options.map(option => ( + onSelect(option.value)} + className='cursor-pointer hover:bg-gray-800' + > + {option.label} + + ))} + + + ); +}; diff --git a/components/hackathons/filters/MobileFilters.tsx b/components/hackathons/filters/MobileFilters.tsx new file mode 100644 index 000000000..1947dc698 --- /dev/null +++ b/components/hackathons/filters/MobileFilters.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; +import { FilterDropdown } from './FilterDropdown'; +import { SearchInput } from './SearchInput'; +import { + sortOptions, + statusOptions, + categoryOptions, + locationOptions, +} from './filter-options'; + +interface MobileFiltersProps { + searchTerm: string; + selectedSort: string; + selectedStatus: string; + selectedCategory: string; + selectedLocation: string; + searchPlaceholder?: string; + onSearch: (value: string) => void; + onSortChange: (value: string) => void; + onStatusChange: (value: string) => void; + onCategoryChange: (value: string) => void; + onLocationChange: (value: string) => void; +} + +export const MobileFilters: React.FC = ({ + searchTerm, + selectedSort, + selectedStatus, + selectedCategory, + selectedLocation, + searchPlaceholder, + onSearch, + onSortChange, + onStatusChange, + onCategoryChange, + onLocationChange, +}) => { + return ( +
+ +
+ + + + +
+
+ ); +}; diff --git a/components/hackathons/filters/SearchInput.tsx b/components/hackathons/filters/SearchInput.tsx new file mode 100644 index 000000000..b4f0b9446 --- /dev/null +++ b/components/hackathons/filters/SearchInput.tsx @@ -0,0 +1,32 @@ +'use client'; + +import React from 'react'; +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export const SearchInput: React.FC = ({ + value, + onChange, + placeholder = 'Search hackathons...', + className, +}) => { + return ( +
+ + onChange(e.target.value)} + className='bg-background w-full rounded-lg border-gray-900 py-3 pr-4 pl-10 text-base text-white placeholder-gray-400 focus:border-gray-400 focus:ring-1 focus:ring-gray-400' + /> +
+ ); +}; diff --git a/components/hackathons/filters/filter-options.ts b/components/hackathons/filters/filter-options.ts new file mode 100644 index 000000000..e101d596d --- /dev/null +++ b/components/hackathons/filters/filter-options.ts @@ -0,0 +1,45 @@ +import { HackathonCategory } from '@/lib/api/hackathons'; + +export interface FilterOption { + label: string; + value: string; +} + +export const sortOptions: FilterOption[] = [ + { label: 'Newest First', value: 'newest' }, + { label: 'Oldest First', value: 'oldest' }, + { label: 'Prize Pool High', value: 'prize_pool_high' }, + { label: 'Prize Pool Low', value: 'prize_pool_low' }, + { label: 'Ending Soon', value: 'deadline_soon' }, + { label: 'Starting Soon', value: 'deadline_far' }, +]; + +export const statusOptions: FilterOption[] = [ + { label: 'All Status', value: 'all' }, + { label: 'Published', value: 'published' }, + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Cancelled', value: 'cancelled' }, +]; + +export const categoryOptions: FilterOption[] = [ + { label: 'All Categories', value: 'all' }, + { label: 'DeFi', value: HackathonCategory.DEFI }, + { label: 'NFTs', value: HackathonCategory.NFTS }, + { label: 'DAOs', value: HackathonCategory.DAOS }, + { label: 'Layer 2', value: HackathonCategory.LAYER_2 }, + { label: 'Cross-chain', value: HackathonCategory.CROSS_CHAIN }, + { label: 'Web3 Gaming', value: HackathonCategory.WEB3_GAMING }, + { label: 'Social Tokens', value: HackathonCategory.SOCIAL_TOKENS }, + { label: 'Infrastructure', value: HackathonCategory.INFRASTRUCTURE }, + { label: 'Privacy', value: HackathonCategory.PRIVACY }, + { label: 'Sustainability', value: HackathonCategory.SUSTAINABILITY }, + { label: 'Real World Assets', value: HackathonCategory.REAL_WORLD_ASSETS }, + { label: 'Other', value: HackathonCategory.OTHER }, +]; + +export const locationOptions: FilterOption[] = [ + { label: 'All Locations', value: 'all' }, + { label: 'Virtual', value: 'virtual' }, + { label: 'Physical', value: 'physical' }, +]; diff --git a/components/landing-page/hackathon/HackathonCard.tsx b/components/landing-page/hackathon/HackathonCard.tsx new file mode 100644 index 000000000..c14435d3b --- /dev/null +++ b/components/landing-page/hackathon/HackathonCard.tsx @@ -0,0 +1,229 @@ +'use client'; +import { Progress } from '@/components/ui/progress'; +import { formatNumber } from '@/lib/utils'; +import { useRouter } from 'nextjs-toploader/app'; +import Image from 'next/image'; +import { MapPinIcon } from 'lucide-react'; +import { useRef, useEffect, useState } from 'react'; + +type HackathonCardProps = { + hackathonId?: string; + organizationName: string; + hackathonSlug?: string; + organizerName: string; + organizerLogo: string; + hackathonImage: string; + hackathonTitle: string; + hackathonDescription: string; + status: 'Published' | 'Ongoing' | 'Completed' | 'Cancelled'; + deadlineInDays: number; + categories: string[]; + location?: string; + venueType?: 'virtual' | 'physical'; + participants?: { + current: number; + goal?: number; + }; + prizePool?: { + total: number; + currency: string; + }; + isFullWidth?: boolean; + isListView?: boolean; +}; + +const formatFullNumber = (num: number): string => + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(num); + +function HackathonCard({ + hackathonId, + organizationName, + hackathonSlug, + organizerName, + organizerLogo, + hackathonImage, + hackathonTitle, + hackathonDescription, + status, + deadlineInDays, + categories, + location, + venueType, + participants, + prizePool, + isFullWidth = false, +}: HackathonCardProps) { + const router = useRouter(); + + const handleClick = () => { + const slug = hackathonSlug || hackathonId || ''; + router.push(`/hackathons/${organizationName}/${slug}`); + }; + + const getStatusColor = () => { + switch (status) { + case 'Ongoing': + return 'text-blue-400 bg-blue-400/10'; + case 'Published': + return 'text-primary bg-primary/10'; + case 'Completed': + return 'text-green-400 bg-green-400/10'; + case 'Cancelled': + return 'text-gray-500 bg-gray-700/20'; + default: + return 'text-gray-400 bg-gray-800/20'; + } + }; + + const getDeadlineInfo = () => { + if (deadlineInDays <= 0) + return { text: 'Ended', className: 'text-gray-500' }; + if (deadlineInDays <= 3) + return { text: `${deadlineInDays} days left`, className: 'text-red-400' }; + if (deadlineInDays <= 15) + return { + text: `${deadlineInDays} days left`, + className: 'text-yellow-400', + }; + return { text: `${deadlineInDays} days left`, className: 'text-green-400' }; + }; + + const deadlineInfo = getDeadlineInfo(); + const locationText = + location || + (venueType === 'virtual' + ? 'Virtual' + : venueType === 'physical' + ? 'Physical' + : 'TBD'); + + const CategoriesDisplay = ({ + categoriesList, + }: { + categoriesList: string[]; + }) => { + const ref = useRef(null); + const [showEllipsis, setShowEllipsis] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const check = () => setShowEllipsis(el.scrollWidth > el.clientWidth); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, [categoriesList]); + + return ( +
+
+ {categoriesList.map((cat, i) => ( + + {cat} + + ))} +
+ {showEllipsis && ( +
+ ... +
+ )} +
+ ); + }; + + return ( +
+ {/* Image */} +
+ {hackathonTitle} +
+ +
+ + + {status} + +
+ +
+
+ + {organizerName} + +
+
+ + {/* Body */} +
+
+

+ {hackathonTitle} +

+

+ {hackathonDescription} +

+
+ +
+ {prizePool && ( +
+ + ${formatFullNumber(prizePool.total)} + + {prizePool.currency} +
+ )} + {participants && ( +
+ + {formatNumber(participants.current)} + {participants.goal && `/${formatNumber(participants.goal)}`} + + Participants +
+ )} + {locationText && locationText !== 'TBD' && ( +
+ + {locationText} +
+ )} +
+ +
+ + {deadlineInfo.text} + + {participants?.goal && ( + + )} +
+
+
+ ); +} + +export default HackathonCard; diff --git a/components/organization/OrganizationContent.tsx b/components/organization/OrganizationContent.tsx index b582e6f46..e68ed4faa 100644 --- a/components/organization/OrganizationContent.tsx +++ b/components/organization/OrganizationContent.tsx @@ -67,14 +67,20 @@ export default function OrganizationContent() { {hasOrganizations && (
{organizations.map(org => ( - + ))} diff --git a/components/organization/cards/GradeSubmissionModal.tsx b/components/organization/cards/GradeSubmissionModal.tsx deleted file mode 100644 index 2fefbe3cb..000000000 --- a/components/organization/cards/GradeSubmissionModal.tsx +++ /dev/null @@ -1,360 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { - X, - ChevronLeft, - ChevronRight, - ArrowUpRight, - Minus, - Plus, -} from 'lucide-react'; -import Image from 'next/image'; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { BoundlessButton } from '@/components/buttons'; -import { cn } from '@/lib/utils'; - -interface GradingCriterion { - id: string; - name: string; - score: number; - maxScore: number; -} - -interface Submission { - id: string; - projectName: string; - category: string; - description: string; - votes: number; - comments: number; - logo?: string; -} - -interface GradeSubmissionModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - submission?: Submission; - submissions?: Submission[]; - currentIndex?: number; - criteria?: GradingCriterion[]; - onGrade?: (submissionId: string, scores: Record) => void; -} - -export default function GradeSubmissionModal({ - open, - onOpenChange, - submission, - submissions = [ - { - id: '1', - projectName: 'Binance', - category: 'Category', - description: - 'To build a secure, transparent, and trusted digital health ecosystem powered by Sonic blockchain for 280M lives in Indonesia.', - votes: 200, - comments: 1000, - logo: '/bitmed.png', - }, - ], - currentIndex = 0, - criteria = [ - { id: 'innovation', name: 'Innovation', score: 1, maxScore: 5 }, - { id: 'impact', name: 'Impact', score: 3, maxScore: 5 }, - { id: 'technical', name: 'Technical Quality', score: 2, maxScore: 5 }, - { id: 'usability', name: 'Usability', score: 1, maxScore: 5 }, - { id: 'presentation', name: 'Presentation', score: 4, maxScore: 5 }, - ], - onGrade, -}: GradeSubmissionModalProps) { - const [scores, setScores] = useState>({}); - const [currentSubmissionIndex, setCurrentSubmissionIndex] = - useState(currentIndex); - const initializedRef = useRef(''); - - const currentSubmission = submission || submissions[currentSubmissionIndex]; - - // Memoize criteria to prevent unnecessary re-renders - const criteriaKey = useMemo( - () => criteria.map(c => `${c.id}-${c.maxScore}-${c.score}`).join(','), - [criteria] - ); - - // Initialize scores from criteria (only when submission or criteria changes) - useEffect(() => { - if (!currentSubmission) return; - - const submissionKey = `${currentSubmission.id}-${criteriaKey}`; - // Only initialize if this is a different submission or criteria changed - if (initializedRef.current !== submissionKey) { - const initialScores: Record = {}; - criteria.forEach(criterion => { - initialScores[criterion.id] = criterion.score; - }); - setScores(initialScores); - initializedRef.current = submissionKey; - } - }, [currentSubmission, criteria, criteriaKey]); - - const canGoPrev = currentSubmissionIndex > 0; - const canGoNext = currentSubmissionIndex < submissions.length - 1; - - const handlePrev = () => { - if (canGoPrev) { - setCurrentSubmissionIndex(currentSubmissionIndex - 1); - } - }; - - const handleNext = () => { - if (canGoNext) { - setCurrentSubmissionIndex(currentSubmissionIndex + 1); - } - }; - - const handleScoreChange = (criterionId: string, delta: number) => { - setScores(prev => { - const currentScore = prev[criterionId] || 0; - const criterion = criteria.find(c => c.id === criterionId); - const maxScore = criterion?.maxScore || 5; - const newScore = Math.max(0, Math.min(maxScore, currentScore + delta)); - return { ...prev, [criterionId]: newScore }; - }); - }; - - const totalScore = Object.values(scores).reduce( - (sum, score) => sum + score, - 0 - ); - const maxTotalScore = criteria.reduce((sum, c) => sum + c.maxScore, 0); - const percentage = - maxTotalScore > 0 ? Math.round((totalScore / maxTotalScore) * 100) : 0; - - const handleSubmit = () => { - if (currentSubmission && onGrade) { - onGrade(currentSubmission.id, scores); - onOpenChange(false); - } - }; - - if (!currentSubmission) return null; - - return ( - - - Grade Submission - - {/* Left Navigation Arrow */} - {submissions.length > 1 && ( - - )} - - {/* Right Navigation Arrow */} - {submissions.length > 1 && ( - - )} - - {/* Header */} - -
- - - - {/* Project Navigation Icons */} -
- {submissions.slice(0, 5).map((sub, index) => ( -
setCurrentSubmissionIndex(index)} - className={cn( - 'flex h-8 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border-2 transition-all', - index === currentSubmissionIndex - ? 'border-primary' - : 'border-gray-800' - )} - > - {sub.projectName} -
- ))} - {submissions.length > 5 && ( -
- +{submissions.length - 5} -
- )} -
-
- 1K+ -
-
- - -
- - {/* Content */} -
- {/* Project Information */} -
-
- {currentSubmission.projectName} -
-
-
-

- {currentSubmission.projectName} -

- - {currentSubmission.category} - -
-
- {currentSubmission.votes} Votes -
- - {currentSubmission.comments.toLocaleString()}+ Comments - -
-

- {currentSubmission.description} -

-
-
- - {/* Grade Submission Section */} -
-

- Grade Submission -

-
- {criteria.map(criterion => { - const score = scores[criterion.id] || 0; - return ( -
- - {criterion.name} - -
- -
- - {score}/{criterion.maxScore} - -
- -
-
- ); - })} -
-
- - {/* Total Score */} -
-
- - Total Score - - - {totalScore}/{maxTotalScore} - -
-
-
-
-
- - ({percentage}%) - -
-
-
- - {/* Footer Actions */} -
- onOpenChange(false)} - className='border-gray-700 bg-transparent text-gray-400 hover:bg-gray-800 hover:text-gray-300' - > - Cancel - - - Submit - -
- -
- ); -} diff --git a/components/organization/cards/GradeSubmissionModal/EmptyCriteriaState.tsx b/components/organization/cards/GradeSubmissionModal/EmptyCriteriaState.tsx new file mode 100644 index 000000000..d6d2f16dd --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/EmptyCriteriaState.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; + +export const EmptyCriteriaState: React.FC = () => { + return ( +
+
+

+ No judging criteria found for this hackathon. +

+

+ Please configure judging criteria in hackathon settings. +

+
+
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/LoadingState.tsx b/components/organization/cards/GradeSubmissionModal/LoadingState.tsx new file mode 100644 index 000000000..1767bba9b --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/LoadingState.tsx @@ -0,0 +1,12 @@ +'use client'; + +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +export const LoadingState: React.FC = () => { + return ( +
+ +
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx b/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx new file mode 100644 index 000000000..41efe08cb --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/ModalFooter.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface ModalFooterProps { + isLoading: boolean; + isFetching: boolean; + isFetchingCriteria: boolean; + hasCriteria: boolean; + existingScore: { scores: unknown[]; notes?: string } | null; + onCancel: () => void; + onSubmit: () => void; +} + +export const ModalFooter = ({ + isLoading, + isFetching, + isFetchingCriteria, + hasCriteria, + existingScore, + onCancel, + onSubmit, +}: ModalFooterProps) => { + return ( +
+
+ Press{' '} + + Enter + {' '} + to navigate •{' '} + + ↑↓ + {' '} + to adjust +
+
+ + +
+
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/ModalHeader.tsx b/components/organization/cards/GradeSubmissionModal/ModalHeader.tsx new file mode 100644 index 000000000..18e8be5aa --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/ModalHeader.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { X, ArrowUpRight } from 'lucide-react'; +import { DialogClose, DialogHeader } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface ModalHeaderProps { + existingScore: { scores: unknown[]; notes?: string } | null; +} + +export const ModalHeader = ({ existingScore }: ModalHeaderProps) => { + return ( + +
+ + + + +
+
+ {existingScore ? 'Update Grade' : 'Grade Submission'} +
+
+
+ + +
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/ProjectHeader.tsx b/components/organization/cards/GradeSubmissionModal/ProjectHeader.tsx new file mode 100644 index 000000000..2a49b72dc --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/ProjectHeader.tsx @@ -0,0 +1,62 @@ +'use client'; + +import Image from 'next/image'; +import { Badge } from '@/components/ui/badge'; + +interface SubmissionData { + id: string; + projectName: string; + category: string; + description?: string; + votes: number; + comments: number; + logo?: string; +} + +interface ProjectHeaderProps { + submission: SubmissionData; +} + +export const ProjectHeader = ({ submission }: ProjectHeaderProps) => { + return ( +
+
+ {submission.projectName} +
+
+
+

+ {submission.projectName} +

+ + {submission.category} + +
+
+ + {submission.votes}{' '} + votes + +
+ + + {submission.comments} + {' '} + comments + +
+ {submission.description && ( +

+ {submission.description} +

+ )} +
+
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx b/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx new file mode 100644 index 000000000..c9450e0c3 --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/ScoringSection.tsx @@ -0,0 +1,136 @@ +'use client'; + +import React from 'react'; +import { AlertCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { JudgingCriterion } from '@/lib/api/hackathons'; + +interface ScoringSectionProps { + criteria: JudgingCriterion[]; + scores: Record; + validationErrors: Record; + focusedInput: string | null; + onScoreChange: (criterionTitle: string, value: string | number) => void; + onInputFocus: (criterionTitle: string) => void; + onInputBlur: (criterionTitle: string) => void; + onKeyDown: ( + e: React.KeyboardEvent, + criterionTitle: string + ) => void; + getScoreColor: (percentage: number) => string; +} + +export const ScoringSection = ({ + criteria, + scores, + validationErrors, + focusedInput, + onScoreChange, + onInputFocus, + onInputBlur, + onKeyDown, + getScoreColor, +}: ScoringSectionProps) => { + const scoredCount = criteria.filter(c => { + const score = scores[c.title]; + return typeof score === 'number' && score > 0; + }).length; + + return ( +
+
+

+ Evaluation Criteria +

+
+ {scoredCount} of{' '} + {criteria.length} scored +
+
+ +
+ {criteria.map(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; + + return ( +
+
+
+
+ + {criterion.title} + + + {criterion.weight}% weight + +
+ {criterion.description && ( +

+ {criterion.description} +

+ )} + {hasError && ( +
+ + Score required +
+ )} +
+ +
+ + 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 +
+
+ + {/* Progress bar */} +
+
+
+
+ ); + })} +
+
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/SuccessOverlay.tsx b/components/organization/cards/GradeSubmissionModal/SuccessOverlay.tsx new file mode 100644 index 000000000..20ab086d7 --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/SuccessOverlay.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { Check } from 'lucide-react'; + +interface SuccessOverlayProps { + show: boolean; +} + +export const SuccessOverlay = ({ show }: SuccessOverlayProps) => { + if (!show) return null; + + return ( +
+
+
+ +
+

Grade Submitted!

+

+ Your evaluation has been saved +

+
+
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx b/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx new file mode 100644 index 000000000..318780193 --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx @@ -0,0 +1,64 @@ +'use client'; + +import Image from 'next/image'; +import { cn } from '@/lib/utils'; + +interface TotalScoreCardProps { + totalScore: number; + percentage: number; + getScoreColor: (percentage: number) => string; +} + +export const TotalScoreCard = ({ + totalScore, + percentage, + getScoreColor, +}: TotalScoreCardProps) => { + return ( +
+
+
+ Metrics +
+
+
+
+
+ Weighted Total Score +
+
+ + {totalScore.toFixed(1)} + + / 100 +
+
+
+ {percentage}% +
+
+ +
+
+
+
+
+ ); +}; diff --git a/components/organization/cards/GradeSubmissionModal/index.tsx b/components/organization/cards/GradeSubmissionModal/index.tsx new file mode 100644 index 000000000..a5e4739f5 --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/index.tsx @@ -0,0 +1,146 @@ +'use client'; + +import React from 'react'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import type { JudgingCriterion } from '@/lib/api/hackathons'; +import { ProjectHeader } from './ProjectHeader'; +import { ScoringSection } from './ScoringSection'; +import { TotalScoreCard } from './TotalScoreCard'; +import { SuccessOverlay } from './SuccessOverlay'; +import { ModalHeader } from './ModalHeader'; +import { ModalFooter } from './ModalFooter'; +import { LoadingState } from './LoadingState'; +import { EmptyCriteriaState } from './EmptyCriteriaState'; +import { useScoreCalculation } from './useScoreCalculation'; +import { useJudgingCriteria } from './useJudgingCriteria'; +import { useSubmissionScores } from './useSubmissionScores'; +import { useScoreForm } from './useScoreForm'; + +interface SubmissionData { + id: string; + projectName: string; + category: string; + description?: string; + votes: number; + comments: number; + logo?: string; +} + +interface GradeSubmissionModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + hackathonId: string; + participantId: string; + judgingCriteria?: JudgingCriterion[]; + submission: SubmissionData; + onSuccess?: () => void; +} + +export default function GradeSubmissionModal({ + open, + onOpenChange, + organizationId, + hackathonId, + participantId, + judgingCriteria, + submission, + onSuccess, +}: GradeSubmissionModalProps) { + const { criteria, isFetchingCriteria } = useJudgingCriteria({ + open, + organizationId, + hackathonId, + initialCriteria: judgingCriteria, + }); + + const { scores, setScores, isFetching, existingScore } = useSubmissionScores({ + open, + organizationId, + hackathonId, + participantId, + criteria, + }); + + const { + focusedInput, + setFocusedInput, + showSuccess, + validationErrors, + isLoading, + handleScoreChange, + handleInputBlur, + handleKeyDown, + handleSubmit, + } = useScoreForm({ + scores, + setScores, + criteria, + organizationId, + hackathonId, + participantId, + existingScore, + onSuccess, + onClose: () => onOpenChange(false), + }); + + const { totalScore, percentage, getScoreColor } = useScoreCalculation({ + criteria, + scores, + }); + + return ( + + + Grade Submission + + + + + +
+ {isFetching || isFetchingCriteria ? ( + + ) : criteria.length === 0 ? ( + + ) : ( + <> + + + + + + + )} +
+ + 0} + existingScore={existingScore} + onCancel={() => onOpenChange(false)} + onSubmit={handleSubmit} + /> +
+
+ ); +} diff --git a/components/organization/cards/GradeSubmissionModal/useJudgingCriteria.ts b/components/organization/cards/GradeSubmissionModal/useJudgingCriteria.ts new file mode 100644 index 000000000..fe85f1f1c --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/useJudgingCriteria.ts @@ -0,0 +1,56 @@ +import { useState, useEffect } from 'react'; +import { getHackathon, type JudgingCriterion } from '@/lib/api/hackathons'; + +interface UseJudgingCriteriaProps { + open: boolean; + organizationId: string; + hackathonId: string; + initialCriteria?: JudgingCriterion[]; +} + +export const useJudgingCriteria = ({ + open, + organizationId, + hackathonId, + initialCriteria, +}: UseJudgingCriteriaProps) => { + const [localCriteria, setLocalCriteria] = useState( + initialCriteria || [] + ); + const [isFetchingCriteria, setIsFetchingCriteria] = useState(false); + + useEffect(() => { + if (initialCriteria && initialCriteria.length > 0) { + setLocalCriteria(initialCriteria); + } + }, [initialCriteria]); + + useEffect(() => { + if ( + open && + organizationId && + hackathonId && + (!localCriteria || localCriteria.length === 0) + ) { + setIsFetchingCriteria(true); + getHackathon(organizationId, hackathonId) + .then(response => { + if (response.success) { + const criteria = response.data.judging?.criteria || []; + if (criteria && criteria.length > 0) { + setLocalCriteria(criteria); + } + } + }) + .catch(() => {}) + .finally(() => { + setIsFetchingCriteria(false); + }); + } + }, [open, organizationId, hackathonId, localCriteria]); + + return { + criteria: localCriteria, + isFetchingCriteria, + }; +}; diff --git a/components/organization/cards/GradeSubmissionModal/useScoreCalculation.ts b/components/organization/cards/GradeSubmissionModal/useScoreCalculation.ts new file mode 100644 index 000000000..df48e1270 --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/useScoreCalculation.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import type { JudgingCriterion } from '@/lib/api/hackathons'; + +interface UseScoreCalculationProps { + criteria: JudgingCriterion[]; + scores: Record; +} + +export const useScoreCalculation = ({ + criteria, + scores, +}: UseScoreCalculationProps) => { + const totalScore = useMemo(() => { + if (criteria.length === 0) return 0; + + let totalWeightedScore = 0; + let totalWeight = 0; + + criteria.forEach(criterion => { + const score = + typeof scores[criterion.title] === 'number' + ? (scores[criterion.title] as number) + : 0; + totalWeightedScore += score * criterion.weight; + totalWeight += criterion.weight; + }); + + return totalWeight > 0 ? totalWeightedScore / 100 : 0; + }, [criteria, scores]); + + const percentage = useMemo(() => Math.round(totalScore), [totalScore]); + + const getScoreColor = (percentage: number): string => { + if (percentage >= 80) return 'bg-success-500'; + if (percentage >= 60) return 'bg-primary'; + if (percentage >= 40) return 'bg-warning-500'; + return 'bg-error-500'; + }; + + return { + totalScore, + percentage, + getScoreColor, + }; +}; diff --git a/components/organization/cards/GradeSubmissionModal/useScoreForm.ts b/components/organization/cards/GradeSubmissionModal/useScoreForm.ts new file mode 100644 index 000000000..8a3db6c58 --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/useScoreForm.ts @@ -0,0 +1,183 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { + submitGrade, + type CriterionScore, + type JudgingCriterion, +} from '@/lib/api/hackathons'; + +interface UseScoreFormProps { + scores: Record; + setScores: React.Dispatch< + React.SetStateAction> + >; + criteria: JudgingCriterion[]; + organizationId: string; + hackathonId: string; + participantId: string; + existingScore: { scores: CriterionScore[]; notes?: string } | null; + onSuccess?: () => void; + onClose: () => void; +} + +export const useScoreForm = ({ + scores, + setScores, + criteria, + organizationId, + hackathonId, + participantId, + existingScore, + onSuccess, + onClose, +}: UseScoreFormProps) => { + const [focusedInput, setFocusedInput] = useState(null); + const [showSuccess, setShowSuccess] = useState(false); + const [validationErrors, setValidationErrors] = useState< + Record + >({}); + const [isLoading, setIsLoading] = useState(false); + + const handleScoreChange = ( + criterionTitle: string, + value: string | number + ) => { + let numValue = typeof value === 'string' ? parseFloat(value) : value; + + if (value === '' || isNaN(numValue)) { + setScores(prev => ({ ...prev, [criterionTitle]: '' })); + setValidationErrors(prev => ({ ...prev, [criterionTitle]: null })); + return; + } + + numValue = Math.max(0, Math.min(100, numValue)); + + setScores(prev => ({ ...prev, [criterionTitle]: numValue })); + setValidationErrors(prev => ({ ...prev, [criterionTitle]: null })); + }; + + const handleInputBlur = (criterionTitle: string) => { + const value = scores[criterionTitle]; + if (value === '' || value === null || value === undefined) { + setScores(prev => ({ ...prev, [criterionTitle]: 0 })); + } + setFocusedInput(null); + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + criterionTitle: string, + onSubmit: () => void + ) => { + const currentScore = + typeof scores[criterionTitle] === 'number' + ? (scores[criterionTitle] as number) + : 0; + + if (e.key === 'ArrowUp') { + e.preventDefault(); + handleScoreChange(criterionTitle, Math.min(100, currentScore + 1)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + handleScoreChange(criterionTitle, Math.max(0, currentScore - 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const currentIndex = criteria.findIndex(c => c.title === criterionTitle); + if (currentIndex < criteria.length - 1) { + setFocusedInput(criteria[currentIndex + 1].title); + } else { + onSubmit(); + } + } + }; + + const handleSubmit = async () => { + const errors: Record = {}; + let hasErrors = false; + + criteria.forEach(criterion => { + const value = scores[criterion.title]; + if (value === '' || value === null || value === undefined) { + errors[criterion.title] = 'Required'; + hasErrors = true; + } + }); + + if (hasErrors) { + setValidationErrors(errors); + toast.error('Please fill in all required scores'); + return; + } + + setIsLoading(true); + + try { + const scoreData: CriterionScore[] = criteria.map(criterion => ({ + criterionTitle: criterion.title, + score: + typeof scores[criterion.title] === 'number' + ? (scores[criterion.title] as number) + : 0, + })); + + const response = await submitGrade( + organizationId, + hackathonId, + participantId, + { + scores: scoreData, + } + ); + + if (response.success) { + setShowSuccess(true); + toast.success( + existingScore + ? 'Grade updated successfully' + : 'Grade submitted successfully', + { + duration: 2000, + } + ); + + if (onSuccess) { + onSuccess(); + } + + setTimeout(() => { + setShowSuccess(false); + onClose(); + }, 2000); + } + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to submit grade. Please try again.'; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (criteria.length > 0) { + setFocusedInput(criteria[0].title); + } + }, [criteria]); + + return { + focusedInput, + setFocusedInput, + showSuccess, + validationErrors, + isLoading, + handleScoreChange, + handleInputBlur, + handleKeyDown: ( + e: React.KeyboardEvent, + criterionTitle: string + ) => handleKeyDown(e, criterionTitle, handleSubmit), + handleSubmit, + }; +}; diff --git a/components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts b/components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts new file mode 100644 index 000000000..78215969e --- /dev/null +++ b/components/organization/cards/GradeSubmissionModal/useSubmissionScores.ts @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import { + getSubmissionScores, + type CriterionScore, + type JudgingCriterion, +} from '@/lib/api/hackathons'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +interface UseSubmissionScoresProps { + open: boolean; + organizationId: string; + hackathonId: string; + participantId: string; + criteria: JudgingCriterion[]; +} + +interface ExistingScore { + scores: CriterionScore[]; + notes?: string; +} + +export const useSubmissionScores = ({ + open, + organizationId, + hackathonId, + participantId, + criteria, +}: UseSubmissionScoresProps) => { + const { user } = useAuthStore(); + const [scores, setScores] = useState>({}); + const [isFetching, setIsFetching] = useState(false); + const [existingScore, setExistingScore] = useState( + null + ); + + const initializeScores = (criteriaList: JudgingCriterion[]) => { + const initialScores: Record = {}; + criteriaList.forEach(criterion => { + initialScores[criterion.title] = 0; + }); + setScores(initialScores); + }; + + useEffect(() => { + if ( + open && + organizationId && + hackathonId && + participantId && + user && + criteria.length > 0 + ) { + setIsFetching(true); + getSubmissionScores(organizationId, hackathonId, participantId) + .then(response => { + if (response.success && response.data.scores) { + const currentUserScore = response.data.scores.find( + score => score.judge._id === user.id + ); + + if (currentUserScore) { + setExistingScore({ + scores: currentUserScore.scores, + notes: currentUserScore.notes || '', + }); + + const initialScores: Record = {}; + currentUserScore.scores.forEach(criterionScore => { + initialScores[criterionScore.criterionTitle] = + criterionScore.score; + }); + setScores(initialScores); + } else { + initializeScores(criteria); + } + } else { + initializeScores(criteria); + } + }) + .catch(() => { + initializeScores(criteria); + }) + .finally(() => { + setIsFetching(false); + }); + } + }, [open, organizationId, hackathonId, participantId, user, criteria]); + + useEffect(() => { + if (!open) { + setScores({}); + setExistingScore(null); + } + }, [open]); + + return { + scores, + setScores, + isFetching, + existingScore, + }; +}; diff --git a/components/organization/cards/JudgingParticipant.tsx b/components/organization/cards/JudgingParticipant.tsx index 90729bfd0..d4cf00fcc 100644 --- a/components/organization/cards/JudgingParticipant.tsx +++ b/components/organization/cards/JudgingParticipant.tsx @@ -1,117 +1,181 @@ 'use client'; -import React, { useState } from 'react'; -import { ArrowUpRight, Mail } from 'lucide-react'; +import React, { useState, useMemo } from 'react'; +import { ArrowUpRight, Mail, Award } from 'lucide-react'; import Image from 'next/image'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; import TeamModal from './TeamModal'; import GradeSubmissionModal from './GradeSubmissionModal'; +import { getHackathon, type JudgingSubmission } from '@/lib/api/hackathons'; -const JudgingParticipant = ({ isSubmitted }: { isSubmitted: boolean }) => { +interface JudgingParticipantProps { + submission: JudgingSubmission; + organizationId: string; + hackathonId: string; + onSuccess?: () => void; +} + +const JudgingParticipant = ({ + submission, + organizationId, + hackathonId, + onSuccess, +}: JudgingParticipantProps) => { const [isTeamModalOpen, setIsTeamModalOpen] = useState(false); - const [isGradeModalOpen, setIsGradeModalOpen] = useState(false); + const [isJudgeModalOpen, setIsJudgeModalOpen] = useState(false); + const [criteria, setCriteria] = useState< + Array<{ title: string; weight: number; description?: string }> + >([]); + const [isLoadingCriteria, setIsLoadingCriteria] = useState(false); + + const participant = submission.participant; + const submissionData = submission.submission; + const userName = `${participant.user.profile.firstName} ${participant.user.profile.lastName}`; + const userAvatar = participant.user.profile.avatar; + const username = participant.user.profile.username; + + // Determine participation type for TeamModal + const participationType = useMemo(() => { + return participant.participationType === 'team' ? 'team' : 'individual'; + }, [participant.participationType]); + + // Fetch criteria when opening judge modal + const handleOpenJudgeModal = async () => { + setIsLoadingCriteria(true); + try { + const response = await getHackathon(organizationId, hackathonId); + if (response.success && response.data.judging?.criteria) { + setCriteria(response.data.judging.criteria); + setIsJudgeModalOpen(true); + } else { + setCriteria([]); + setIsJudgeModalOpen(true); + } + } catch { + setCriteria([]); + setIsJudgeModalOpen(true); + } finally { + setIsLoadingCriteria(false); + } + }; return ( -
+
- - N + + {userName.charAt(0).toUpperCase()} -

Participant Name

-

@participant

+

{userName}

+

@{username}

- - Telegram - - - Github - - - - + {participant.user.profile.avatar && ( + + + + )}
- + {(participant.participationType === 'team' || + participant.teamName) && ( + + )}
- {isSubmitted && ( -
-
-
- Telegram +
+
+
+ {submissionData.projectName} +
+
+
+
+ {submissionData.projectName} +
+ + {submissionData.category} +
-
-
-
Project Name
- - Category - -
-
- 200 Votes -
- 1k+ Comments -
+
+ {submission.averageScore !== null && ( + <> + + Avg Score: {submission.averageScore.toFixed(2)} + +
+ + )} + + {submission.judgeCount} Judge + {submission.judgeCount !== 1 ? 's' : ''} +
-

- To build a secure, transparent, and trusted digital health ecosystem - powered by Sonic blockchain for 280M lives in Indonesia. -

-
- 12 Oct, 2025 - -
- )} +

+ {submissionData.description} +

+
+ + {new Date(submissionData.submissionDate).toLocaleDateString( + 'en-US', + { + day: 'numeric', + month: 'short', + year: 'numeric', + } + )} + + +
+
{/* Team Modal */} { // TODO: Navigate to team details page or show team information }} @@ -119,22 +183,25 @@ const JudgingParticipant = ({ isSubmitted }: { isSubmitted: boolean }) => { {/* Grade Submission Modal */} { - // TODO: Implement API call to submit grades - void submissionId; - void scores; + onSuccess={() => { + if (onSuccess) { + onSuccess(); + } }} />
diff --git a/components/organization/cards/MetricsCard.tsx b/components/organization/cards/MetricsCard.tsx index 45afc9b07..b8f0e2758 100644 --- a/components/organization/cards/MetricsCard.tsx +++ b/components/organization/cards/MetricsCard.tsx @@ -3,7 +3,24 @@ import { Triangle } from 'lucide-react'; import Image from 'next/image'; import React from 'react'; -const MetricsCard = () => { +interface MetricsCardProps { + title: string; + value: string | number; + subtitle?: string; + icon?: string; + showTrend?: boolean; +} + +const MetricsCard = ({ + title, + value, + subtitle, + icon = '/user-border.svg', + showTrend = false, +}: MetricsCardProps) => { + const formattedValue = + typeof value === 'number' ? value.toLocaleString() : value; + return ( { > Metrics { /> -
Total Participants
+
{title}
- {' '} - 1,234 + {showTrend && ( + + )}{' '} + {formattedValue} - 2.3% of boundless users + {subtitle && {subtitle}}
); diff --git a/components/organization/cards/OrganzationCards.tsx b/components/organization/cards/OrganzationCards.tsx index 5a097a562..79dfc9b6b 100644 --- a/components/organization/cards/OrganzationCards.tsx +++ b/components/organization/cards/OrganzationCards.tsx @@ -44,7 +44,7 @@ export default function OrganizationCard({
{`${name} { +interface ParticipantProps { + participant: ParticipantType; + organizationId?: string; + hackathonId?: string; + onReviewSuccess?: () => void; +} + +const Participant = ({ + participant, + organizationId, + hackathonId, + onReviewSuccess, +}: ParticipantProps) => { const [isTeamModalOpen, setIsTeamModalOpen] = useState(false); const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [isJudgeModalOpen, setIsJudgeModalOpen] = useState(false); + const [criteria, setCriteria] = useState< + Array<{ title: string; weight: number; description?: string }> + >([]); + const [isLoadingCriteria, setIsLoadingCriteria] = useState(false); + + const isSubmitted = !!participant.submission; + const submissionData = useParticipantSubmission(participant); + + // Determine participation type for TeamModal + const participationType = useMemo(() => { + if (isSubmitted) { + return participant.participationType === 'team' ? 'team' : 'individual'; + } + return 'no-submission'; + }, [isSubmitted, participant.participationType]); + + // Check if submission is shortlisted + const isShortlisted = useMemo(() => { + return participant.submission?.status === 'shortlisted'; + }, [participant.submission?.status]); + + // Fetch criteria when opening judge modal + const handleOpenJudgeModal = async () => { + if (!organizationId || !hackathonId) return; + + setIsLoadingCriteria(true); + try { + const response = await getHackathon(organizationId, hackathonId); + if (response.success && response.data.judging?.criteria) { + setCriteria(response.data.judging.criteria); + setIsJudgeModalOpen(true); + } else { + // If no criteria, still open modal but with empty criteria + setCriteria([]); + setIsJudgeModalOpen(true); + } + } catch { + // Open modal anyway, criteria will be fetched in the modal + setCriteria([]); + setIsJudgeModalOpen(true); + } finally { + setIsLoadingCriteria(false); + } + }; return (
{ isSubmitted ? 'grid-cols-2' : 'grid-cols-1' )} > -
-
- - - N - -

Participant Name

-

@participant

-
-
-
- - Telegram - - - Github - - - - -
-
- -
-
-
- {isSubmitted && ( -
-
-
- Telegram -
-
-
-
Project Name
- - Category - -
-
- 200 Votes -
- 1k+ Comments -
-
-
-

- To build a secure, transparent, and trusted digital health ecosystem - powered by Sonic blockchain for 280M lives in Indonesia. -

-
- 12 Oct, 2025 - -
-
+ setIsTeamModalOpen(true)} + /> + {isSubmitted && participant.submission && ( + setIsReviewModalOpen(true)} + onGradeClick={handleOpenJudgeModal} + /> )} {/* Team Modal */} ({ + id: member.userId, + name: member.name, + role: member.role, + avatar: member.avatar, + }))} + teamId={participant.teamId} + organizationId={organizationId} + hackathonId={hackathonId} onTeamClick={() => { // TODO: Navigate to team details page or show team information }} /> {/* Review Submission Modal */} - { - // TODO: Implement API call to shortlist submission - void submissionId; - }} - onDisqualify={submissionId => { - // TODO: Implement API call to disqualify submission - // This should mark submission as DISQUALIFIED (soft delete, not permanent) - void submissionId; - }} - /> + {submissionData && ( + + )} + + {/* Grade Submission Modal */} + {isShortlisted && + organizationId && + hackathonId && + participant.submission && ( + { + if (onReviewSuccess) { + onReviewSuccess(); + } + }} + /> + )}
); }; diff --git a/components/organization/cards/ParticipantInfo.tsx b/components/organization/cards/ParticipantInfo.tsx new file mode 100644 index 000000000..f31203365 --- /dev/null +++ b/components/organization/cards/ParticipantInfo.tsx @@ -0,0 +1,50 @@ +'use client'; + +import React from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { ParticipantSocialLinks } from './ParticipantSocialLinks'; +import type { Participant as ParticipantType } from '@/lib/api/hackathons'; + +interface ParticipantInfoProps { + participant: ParticipantType; + onTeamClick: () => void; +} + +export const ParticipantInfo: React.FC = ({ + participant, + onTeamClick, +}) => { + const userName = `${participant.user.profile.firstName} ${participant.user.profile.lastName}`; + const userAvatar = participant.user.profile.avatar; + const username = participant.user.profile.username; + const hasTeam = + participant.participationType === 'team' || participant.teamMembers; + + return ( +
+
+ + + {userName.charAt(0).toUpperCase()} + +

{userName}

+

@{username}

+
+
+ +
+ {hasTeam && ( + + )} +
+
+
+ ); +}; diff --git a/components/organization/cards/ParticipantSocialLinks.tsx b/components/organization/cards/ParticipantSocialLinks.tsx new file mode 100644 index 000000000..5a8afed75 --- /dev/null +++ b/components/organization/cards/ParticipantSocialLinks.tsx @@ -0,0 +1,52 @@ +'use client'; + +import React from 'react'; +import { Mail } from 'lucide-react'; +import Image from 'next/image'; +import type { Participant as ParticipantType } from '@/lib/api/hackathons'; + +interface ParticipantSocialLinksProps { + socialLinks?: ParticipantType['socialLinks']; +} + +export const ParticipantSocialLinks: React.FC = ({ + socialLinks, +}) => { + if (!socialLinks) return null; + + return ( +
+ {socialLinks.telegram && ( + + Telegram + + )} + {socialLinks.github && ( + + Github + + )} + {socialLinks.email && ( + + + + )} +
+ ); +}; diff --git a/components/organization/cards/ParticipantSubmission.tsx b/components/organization/cards/ParticipantSubmission.tsx new file mode 100644 index 000000000..15abe79e1 --- /dev/null +++ b/components/organization/cards/ParticipantSubmission.tsx @@ -0,0 +1,105 @@ +'use client'; + +import React from 'react'; +import { ArrowUpRight, Award } from 'lucide-react'; +import Image from 'next/image'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import type { Participant as ParticipantType } from '@/lib/api/hackathons'; + +interface ParticipantSubmissionProps { + participant: ParticipantType; + isShortlisted: boolean; + organizationId?: string; + hackathonId?: string; + isLoadingCriteria: boolean; + onReviewClick: () => void; + onGradeClick: () => void; +} + +export const ParticipantSubmission: React.FC = ({ + participant, + isShortlisted, + organizationId, + hackathonId, + isLoadingCriteria, + onReviewClick, + onGradeClick, +}) => { + if (!participant.submission) return null; + + const votesCount = Array.isArray(participant.submission.votes) + ? participant.submission.votes.length + : participant.submission.votes; + const commentsCount = Array.isArray(participant.submission.comments) + ? participant.submission.comments.length + : participant.submission.comments || 0; + + return ( +
+
+
+ {participant.submission.projectName} +
+
+
+
+ {participant.submission.projectName} +
+ + {participant.submission.category} + +
+
+ {votesCount} Votes +
+ + {commentsCount.toLocaleString()}+ Comments + +
+
+
+

+ {participant.submission.description} +

+
+ + {new Date(participant.submission.submissionDate).toLocaleDateString( + 'en-US', + { + day: 'numeric', + month: 'short', + year: 'numeric', + } + )} + +
+ {isShortlisted && organizationId && hackathonId && ( + + )} + +
+
+
+ ); +}; diff --git a/components/organization/cards/RejectSubmissionModal.tsx b/components/organization/cards/RejectSubmissionModal.tsx index 1825d47e7..b5fbea85a 100644 --- a/components/organization/cards/RejectSubmissionModal.tsx +++ b/components/organization/cards/RejectSubmissionModal.tsx @@ -30,7 +30,7 @@ export default function RejectSubmissionModal({ onConfirm, }: RejectSubmissionModalProps) { const [comment, setComment] = useState(''); - const maxCommentLength = 300; + const maxCommentLength = 1000; // Matches API requirement const handleConfirm = () => { onConfirm?.(comment.trim() || undefined); diff --git a/components/organization/cards/ReviewSubmissionModal.tsx b/components/organization/cards/ReviewSubmissionModal.tsx index 10e1146b2..e79659f72 100644 --- a/components/organization/cards/ReviewSubmissionModal.tsx +++ b/components/organization/cards/ReviewSubmissionModal.tsx @@ -1,243 +1,50 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { - X, - ChevronLeft, - ChevronRight, - ArrowUpRight, - MessageSquare, - ThumbsUp, - Heart, -} from 'lucide-react'; -import Image from 'next/image'; -import { toast } from 'sonner'; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, -} from '@/components/ui/dialog'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; -import { - VideoPlayer, - VideoPlayerContent, - VideoPlayerControlBar, - VideoPlayerPlayButton, - VideoPlayerSeekBackwardButton, - VideoPlayerSeekForwardButton, - VideoPlayerTimeRange, - VideoPlayerTimeDisplay, - VideoPlayerMuteButton, - VideoPlayerVolumeRange, -} from '@/components/ui/shadcn-io/video-player'; import SubmissionActionButtons from './SubmissionActionButtons'; import RejectSubmissionModal from './RejectSubmissionModal'; -import Link from 'next/link'; - -interface TeamMember { - id: string; - name: string; - role: string; - avatar?: string; - username?: string; -} - -interface Voter { - id: string; - name: string; - username: string; - avatar?: string; - votedAt?: string; - voteType?: 'positive' | 'negative'; -} - -interface Comment { - id: string; - content: string; - author: { - name: string; - username: string; - avatar?: string; - }; - createdAt: string; - reactions?: { - thumbsUp?: number; - heart?: number; - }; -} - -interface Submission { - id: string; - projectName: string; - category: string; - description: string; - votes: number; - comments: number; - submissionDate: string; - videoUrl?: string; - introduction?: string; - logo?: string; - teamMembers?: TeamMember[]; - links?: Array<{ type: string; url: string }>; - voters?: Voter[]; - commentsList?: Comment[]; -} - -interface ReviewSubmissionModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - submissions?: Submission[]; - currentIndex?: number; - onShortlist?: (submissionId: string) => void; - onDisqualify?: (submissionId: string) => void; -} +import { SubmissionModalHeader } from './ReviewSubmissionModal/SubmissionModalHeader'; +import { SubmissionInfo } from './ReviewSubmissionModal/SubmissionInfo'; +import { SubmissionDetailsTab } from './ReviewSubmissionModal/SubmissionDetailsTab'; +import { SubmissionLinksTab } from './ReviewSubmissionModal/SubmissionLinksTab'; +import { SubmissionVotesTab } from './ReviewSubmissionModal/SubmissionVotesTab'; +import { SubmissionCommentsTab } from './ReviewSubmissionModal/SubmissionCommentsTab'; +import { useSubmissionActions } from '@/hooks/use-submission-actions'; +import type { ReviewSubmissionModalProps } from './ReviewSubmissionModal/types'; export default function ReviewSubmissionModal({ open, onOpenChange, - submissions = [ - { - id: '1', - projectName: 'Binance', - category: 'Category', - description: - 'To build a secure, transparent, and trusted digital health ecosystem powered by Sonic blockchain for 280M lives in Indonesia.', - votes: 200, - comments: 1000, - submissionDate: '12 Oct, 2025', - videoUrl: 'https://www.youtube.com/watch?v=1kMn8EZCmXM', - introduction: - 'Bitmed is redefining healthcare access and trust through blockchain technology. By leveraging the speed and scalability of Sonic blockchain, Bitmed ensures that health data, patient records, and transactions remain tamper-proof, accessible, and transparent for all stakeholders in the healthcare ecosystem.', - logo: '/bitmed.png', - teamMembers: [ - { - id: '1', - name: "Creator's Name", - role: 'TEAM LEAD', - avatar: 'https://github.com/shadcn.png', - username: 'verydarkman', - }, - { - id: '2', - name: "Creator's Name", - role: 'Member', - avatar: 'https://github.com/shadcn.png', - username: 'verydarkman', - }, - { - id: '3', - name: "Creator's Name", - role: 'Member', - avatar: 'https://github.com/shadcn.png', - username: 'verydarkman', - }, - { - id: '4', - name: "Creator's Name", - role: 'Member', - avatar: 'https://github.com/shadcn.png', - username: 'verydarkman', - }, - { - id: '5', - name: "Creator's Name", - role: 'Member', - avatar: 'https://github.com/shadcn.png', - username: 'verydarkman', - }, - ], - links: [ - { type: 'github', url: 'https://github.com' }, - { type: 'website', url: 'https://example.com' }, - ], - voters: [ - { - id: '1', - name: 'John Doe', - username: 'johndoe', - avatar: 'https://github.com/shadcn.png', - votedAt: '2025-01-15T10:30:00Z', - voteType: 'positive', - }, - { - id: '2', - name: 'Jane Smith', - username: 'janesmith', - avatar: 'https://github.com/shadcn.png', - votedAt: '2025-01-14T15:20:00Z', - voteType: 'positive', - }, - { - id: '3', - name: 'Bob Johnson', - username: 'bobjohnson', - avatar: 'https://github.com/shadcn.png', - votedAt: '2025-01-13T09:15:00Z', - voteType: 'negative', - }, - ], - commentsList: [ - { - id: '1', - content: - 'This is an amazing project! The blockchain integration looks solid and the use case is very relevant.', - author: { - name: 'Alice Williams', - username: 'alicew', - avatar: 'https://github.com/shadcn.png', - }, - createdAt: '2025-01-15T11:00:00Z', - reactions: { - thumbsUp: 12, - heart: 3, - }, - }, - { - id: '2', - content: - 'I have some concerns about the scalability. How will this handle 280M users?', - author: { - name: 'Charlie Brown', - username: 'charlieb', - avatar: 'https://github.com/shadcn.png', - }, - createdAt: '2025-01-14T16:30:00Z', - reactions: { - thumbsUp: 5, - }, - }, - { - id: '3', - content: - 'Great presentation! The video was very informative. Looking forward to seeing this in action.', - author: { - name: 'Diana Prince', - username: 'dianap', - avatar: 'https://github.com/shadcn.png', - }, - createdAt: '2025-01-13T14:20:00Z', - reactions: { - thumbsUp: 8, - heart: 2, - }, - }, - ], - }, - ], + submissions = [], currentIndex = 0, + organizationId, + hackathonId, + participantId, + onSuccess, onShortlist, onDisqualify, }: ReviewSubmissionModalProps) { const [activeTab, setActiveTab] = useState('details'); const [currentSubmissionIndex, setCurrentSubmissionIndex] = useState(currentIndex); - const [isDisqualifyModalOpen, setIsDisqualifyModalOpen] = useState(false); + + const { + isDisqualifyModalOpen, + setIsDisqualifyModalOpen, + handleShortlist, + handleDisqualifyClick, + handleDisqualifyConfirm, + } = useSubmissionActions({ + organizationId, + hackathonId, + participantId, + onSuccess, + onShortlist, + onDisqualify, + }); // Update index when submissions array changes (e.g., after shortlisting/rejecting) useEffect(() => { @@ -273,38 +80,14 @@ export default function ReviewSubmissionModal({ } }; - const handleShortlist = () => { - if (currentSubmission && onShortlist) { - const submissionId = currentSubmission.id; - - // Call the shortlist callback (marks submission as SHORTLISTED for judging) - onShortlist(submissionId); - - // Show success toast (non-intrusive, shorter duration) - toast.success('Submission added to shortlist', { - duration: 1500, - }); - - // Don't auto-navigate - let organizer decide when to move to next - // They may want to review the shortlisted submission or reconsider - } - }; - - const handleDisqualifyClick = () => { - setIsDisqualifyModalOpen(true); + const handleShortlistClick = () => { + if (!currentSubmission) return; + handleShortlist(currentSubmission.id); }; - const handleDisqualifyConfirm = () => { - if (currentSubmission && onDisqualify) { - // Call the disqualify callback with optional comment - // This should mark submission as DISQUALIFIED (soft delete, not permanent) - onDisqualify(currentSubmission.id); - - // Show success toast - toast.success('Submission marked as disqualified', { - duration: 1500, - }); - } + const handleDisqualifyConfirmWrapper = (comment?: string) => { + if (!currentSubmission) return; + handleDisqualifyConfirm(currentSubmission.id, comment); }; if (!currentSubmission) return null; @@ -312,473 +95,110 @@ export default function ReviewSubmissionModal({ return ( - {/* Header */} - -
- - - -
- -
- {/* Carousel Navigation */} -
- - - {currentSubmissionIndex + 1} / {submissions.length} - - -
- - -
-
- -
- {/* Left Column - Submission & Team Details */} -
-
-
- {currentSubmission.projectName} -
-
-
-

- {currentSubmission.projectName} -

- - {currentSubmission.category} - -
-
- {currentSubmission.votes} Votes -
- - {currentSubmission.comments.toLocaleString()}+ Comments - -
-
-
- -

- {currentSubmission.description} -

- - {/* Team Section */} - {currentSubmission.teamMembers && - currentSubmission.teamMembers.length > 0 && ( -
-

- TEAM -

- -
- {currentSubmission.teamMembers.map(member => ( -
- - - - {member.name.charAt(0).toUpperCase()} - - -
-

- {member.name} -

- {member.username && ( -

- @{member.username} -

- )} -

- {member.role} -

-
- -
- ))} -
-
-
- )} -
- - {/* Right Column - Project Content */} -
- - - - Details - - - Links - - - Votes ({currentSubmission.votes}) - - - Comments ({currentSubmission.comments.toLocaleString()}+) - - - - {/* Scrollable Tabs Content Area */} -
- -
- {/* Video Section */} -
-

- How {currentSubmission.projectName} Works in 2 Minutes -

-
- {currentSubmission.videoUrl ? ( - (() => { - // Check if it's a YouTube URL - const isYouTube = - /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)/.test( - currentSubmission.videoUrl - ); - - if (isYouTube) { - // Convert YouTube URL to embed format - const getYouTubeEmbedUrl = (url: string) => { - const regExp = - /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; - const match = url.match(regExp); - const videoId = - match && match[2].length === 11 - ? match[2] - : null; - return videoId - ? `https://www.youtube.com/embed/${videoId}` - : url; - }; - - return ( -