From 9dc0742915008e7400b6c96eecb43bf2507190a9 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Fri, 13 Mar 2026 13:59:08 +0100 Subject: [PATCH 1/3] feat: Implement comprehensive hackathon detail page with new header, sidebar, and tabbed content sections including teams, announcements, winners, participants, and resources. --- .../hackathons/[slug]/HackathonPageClient.tsx | 83 +-- .../announcements/[announcementId]/page.tsx | 9 +- .../hackathons/[slug]/components/Banner.tsx | 18 + .../components/header/ActionButtons.tsx | 139 ++++ .../[slug]/components/header/Logo.tsx | 20 + .../[slug]/components/header/TitleAndInfo.tsx | 145 ++++ .../[slug]/components/header/index.tsx | 34 + .../components/sidebar/FollowAndMessage.tsx | 160 +++++ .../components/sidebar/PoolAndAction.tsx | 270 +++++++ .../[slug]/components/sidebar/index.tsx | 14 + .../[slug]/components/tabs/Lists.tsx | 51 ++ .../tabs/contents/AnnouncementsTab.tsx | 15 + .../components/tabs/contents/Discussions.tsx | 31 + .../components/tabs/contents/FindTeam.tsx | 244 +++++++ .../components/tabs/contents/Overview.tsx | 274 +++++++ .../components/tabs/contents/Participants.tsx | 203 ++++++ .../components/tabs/contents/ResourcesTab.tsx | 14 + .../components/tabs/contents/Submissions.tsx | 246 +++++++ .../components/tabs/contents/Winners.tsx | 98 +++ .../announcements/announcementCard.tsx | 123 ++++ .../tabs/contents/announcements/header.tsx | 46 ++ .../tabs/contents/announcements/index.tsx | 96 +++ .../[slug]/components/tabs/contents/index.tsx | 0 .../contents/participants/ParticipantCard.tsx | 84 +++ .../tabs/contents/resources/ResourceCard.tsx | 93 +++ .../tabs/contents/resources/header.tsx | 44 ++ .../tabs/contents/resources/index.tsx | 169 +++++ .../contents/submissions/SubmissionCard.tsx | 91 +++ .../tabs/contents/teams/MyTeamView.tsx | 377 ++++++++++ .../tabs/contents/teams/TeamCard.tsx | 109 +++ .../contents/winners/GeneralWinnerCard.tsx | 33 + .../tabs/contents/winners/MainStageHeader.tsx | 14 + .../contents/winners/PodiumWinnerCard.tsx | 53 ++ .../tabs/contents/winners/TopWinnerCard.tsx | 102 +++ .../[slug]/components/tabs/index.tsx | 202 ++++++ .../[slug]/hackathon-detail-design.md | 2 + app/(landing)/hackathons/[slug]/page.tsx | 18 +- .../hackathons/[slug]/submit/page.tsx | 16 +- app/(landing)/hackathons/layout.tsx | 2 +- app/providers.tsx | 42 +- components/avatars/BasicAvatar.tsx | 31 + components/avatars/GroupAvatar.tsx | 41 ++ components/common/SharePopover.tsx | 113 +++ components/common/share.tsx | 3 + components/hackathons/ExtendedBadge.tsx | 34 + .../hackathons/submissions/SubmissionForm.tsx | 25 +- .../team-formation/ContactTeamModal.tsx | 154 ++++ .../team-formation/CreateTeamPostModal.tsx | 677 +++++++++--------- .../team-formation/TeamDetailsSheet.tsx | 60 +- .../team-formation/TeamFormationTab.tsx | 8 +- .../TeamRecruitmentPostCard.tsx | 36 +- components/ui/avatar.tsx | 66 +- components/ui/popover-cult.tsx | 352 +++++++++ components/ui/separator.tsx | 2 +- components/ui/tabs.tsx | 43 +- hooks/hackathon/use-hackathon-queries.ts | 492 +++++++++++++ hooks/hackathon/use-hackathon-submissions.ts | 12 +- hooks/hackathon/use-team-posts.ts | 49 +- hooks/use-follow.ts | 8 +- lib/api/hackathon.ts | 2 +- lib/api/hackathons.ts | 12 +- lib/api/hackathons/core.ts | 2 +- lib/api/hackathons/teams.ts | 468 ++++++------ lib/providers/hackathonProvider.tsx | 544 +++----------- package-lock.json | 519 +++++++++++++- package.json | 5 +- types/follow.ts | 8 +- types/hackathon/participant.ts | 1 + 68 files changed, 6296 insertions(+), 1255 deletions(-) create mode 100644 app/(landing)/hackathons/[slug]/components/Banner.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/header/Logo.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/header/TitleAndInfo.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/header/index.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/sidebar/FollowAndMessage.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/sidebar/index.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/Lists.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/AnnouncementsTab.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/Discussions.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/Participants.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/ResourcesTab.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/Submissions.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/Winners.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/announcementCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/header.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/index.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/index.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/participants/ParticipantCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/resources/ResourceCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/resources/header.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/resources/index.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/winners/MainStageHeader.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/winners/PodiumWinnerCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/contents/winners/TopWinnerCard.tsx create mode 100644 app/(landing)/hackathons/[slug]/components/tabs/index.tsx create mode 100644 components/avatars/BasicAvatar.tsx create mode 100644 components/avatars/GroupAvatar.tsx create mode 100644 components/common/SharePopover.tsx create mode 100644 components/common/share.tsx create mode 100644 components/hackathons/ExtendedBadge.tsx create mode 100644 components/hackathons/team-formation/ContactTeamModal.tsx create mode 100644 components/ui/popover-cult.tsx create mode 100644 hooks/hackathon/use-hackathon-queries.ts diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index 3f38dce0..7559ac8b 100644 --- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useRouter, useSearchParams, useParams } from 'next/navigation'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useHackathonAnnouncements } from '@/hooks/hackathon/use-hackathon-queries'; import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon'; import { useLeaveHackathon } from '@/hooks/hackathon/use-leave-hackathon'; import { useSubmission } from '@/hooks/hackathon/use-submission'; @@ -19,31 +20,34 @@ import { WinnersTab } from '@/components/hackathons/winners/WinnersTab'; import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen'; import { useTimelineEvents } from '@/hooks/hackathon/use-timeline-events'; import { toast } from 'sonner'; -import type { Participant } from '@/lib/api/hackathons'; +import type { Hackathon, Participant } from '@/lib/api/hackathons'; import { HackathonStickyCard } from '@/components/hackathons/hackathonStickyCard'; import { HackathonParticipants } from '@/components/hackathons/participants/hackathonParticipant'; 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 { Megaphone } from 'lucide-react'; import { AnnouncementsTab } from '@/components/hackathons/announcements/AnnouncementsTab'; -import { reportError, reportMessage } from '@/lib/error-reporting'; +import { reportMessage } from '@/lib/error-reporting'; -export default function HackathonPageClient() { +interface HackathonPageClientProps { + /** Server-fetched hackathon — seeds React Query cache, eliminates loading state on first render. */ + initialHackathon: Hackathon; +} + +export default function HackathonPageClient({ + initialHackathon, +}: HackathonPageClientProps) { const router = useRouter(); const searchParams = useSearchParams(); const params = useParams(); + // `currentHackathon` is immediately available — seeded from server via initialData const { currentHackathon, submissions, winners, loading, - setCurrentHackathon, refreshCurrentHackathon, } = useHackathonData(); @@ -78,32 +82,11 @@ export default function HackathonPageClient() { autoFetch: !!hackathonId, }); - // Fetch announcements for public view - const [announcements, setAnnouncements] = useState( - [] + // React Query replaces the useEffect+useState announcements pattern + const { data: announcements = [] } = useHackathonAnnouncements( + hackathonId, + !!hackathonId ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - 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) { - reportError(error, { - context: 'hackathon-fetchAnnouncements', - hackathonId, - }); - } finally { - setAnnouncementsLoading(false); - } - } - fetchAnnouncements(); - }, [hackathonId]); const hackathonTabs = useMemo(() => { const hasParticipants = @@ -318,33 +301,13 @@ export default function HackathonPageClient() { router.push('?tab=team-formation'); }; - // Set current hackathon on mount - const [isInitializing, setIsInitializing] = useState(true); - - useEffect(() => { - let isMounted = true; - - const initHackathon = async () => { - if (hackathonId) { - await setCurrentHackathon(hackathonId); - } - if (isMounted) { - setIsInitializing(false); - } - }; - - initHackathon(); - - return () => { - isMounted = false; - }; - }, [hackathonId, setCurrentHackathon]); + // No longer needed — currentHackathon is seeded from server via React Query initialData. - // Handle tab changes from URL - // Now also defaults to 'overview' if the URL tab is not in the filtered hackathonTabs list. + // Handle tab changes from URL. + // Defaults to 'overview' if the URL tab is not in the filtered hackathonTabs list. // This handles direct URL access to a disabled tab — user is silently redirected to overview. useEffect(() => { - if (loading || !currentHackathon) return; + if (!currentHackathon) return; const tabFromUrl = searchParams.get('tab'); @@ -365,7 +328,7 @@ export default function HackathonPageClient() { const queryParams = new URLSearchParams(searchParams.toString()); queryParams.set('tab', 'overview'); router.replace(`?${queryParams.toString()}`, { scroll: false }); - }, [searchParams, hackathonTabs, router, loading, currentHackathon]); + }, [searchParams, hackathonTabs, router, currentHackathon]); const handleTabChange = (tabId: string) => { setActiveTab(tabId); @@ -374,8 +337,8 @@ export default function HackathonPageClient() { router.push(`?${queryParams.toString()}`, { scroll: false }); }; - // Loading state - if (loading || isInitializing) { + // Only show a loading screen if data is still being fetched (e.g., window refocus refresh). + if (loading && !currentHackathon) { return ; } diff --git a/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx index b31da2d7..195c4f40 100644 --- a/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx +++ b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx @@ -93,13 +93,13 @@ export default function AnnouncementDetailPage() {
{/* Top Header */}
-
+
@@ -170,7 +170,6 @@ export default function AnnouncementDetailPage() {
- {/* Content */}
{markdownLoading ? ( @@ -191,7 +190,7 @@ export default function AnnouncementDetailPage() { This announcement was published by the hackathon organizers.

window.close()} + onClick={() => router.push(`/hackathons/${slug}?tab=announcements`)} variant='outline' size='sm' > diff --git a/app/(landing)/hackathons/[slug]/components/Banner.tsx b/app/(landing)/hackathons/[slug]/components/Banner.tsx new file mode 100644 index 00000000..62c2cfe7 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/Banner.tsx @@ -0,0 +1,18 @@ +import Image from 'next/image'; +import React from 'react'; + +const Banner = ({ banner, title }: { banner: string; title?: string }) => { + return ( +
+ {`${title +
+ ); +}; + +export default Banner; diff --git a/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx b/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx new file mode 100644 index 00000000..027ce7f2 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { BoundlessButton } from '@/components/buttons'; +import { + IconUsers, + IconUserPlus, + IconLogout, + IconChevronDown, +} from '@tabler/icons-react'; +import { + useHackathon, + useMyTeam, + useHackathonParticipants, + useJoinHackathon, + useLeaveHackathon, +} from '@/hooks/hackathon/use-hackathon-queries'; +import { useAuth } from '@/hooks/use-auth'; +import SharePopover from '@/components/common/SharePopover'; +import { + PopoverRoot, + PopoverTrigger, + PopoverContent, + PopoverBody, + PopoverButton, +} from '@/components/ui/popover-cult'; +import { toast } from 'sonner'; + +const ActionButtons = () => { + const { slug } = useParams<{ slug: string }>(); + const router = useRouter(); + const { user } = useAuth(); + const { data: hackathon } = useHackathon(slug); + const { data: myTeam } = useMyTeam(slug); + const { data: participantsData } = useHackathonParticipants(slug); + const participants = participantsData?.participants || []; + + const joinMutation = useJoinHackathon(slug); + const leaveMutation = useLeaveHackathon(slug); + + const isParticipant = user + ? participants.some((p: any) => p.userId === user.id) + : false; + + const handleJoin = async () => { + try { + await joinMutation.mutateAsync(); + toast.success('Successfully joined the hackathon!'); + } catch (error: any) { + toast.error(error.message || 'Failed to join hackathon'); + } + }; + + const handleLeave = async () => { + try { + await leaveMutation.mutateAsync(); + toast.success('You have left the hackathon'); + } catch (error: any) { + toast.error(error.message || 'Failed to leave hackathon'); + } + }; + + const handleTabChange = (tab: string) => { + const searchParams = new URLSearchParams(window.location.search); + searchParams.set('tab', tab); + router.push(`?${searchParams.toString()}`); + + // Smooth scroll to tabs + const tabsElement = document.getElementById('hackathon-tabs'); + if (tabsElement) { + tabsElement.scrollIntoView({ behavior: 'smooth' }); + } + }; + + const isRegistrationClosed = + hackathon?.registrationOpen === false || + (hackathon?.registrationDeadline && + new Date(hackathon.registrationDeadline) < new Date()) || + ['JUDGING', 'COMPLETED', 'ARCHIVED', 'CANCELLED'].includes( + hackathon?.status || '' + ); + + const isIndividualOnly = hackathon?.participantType === 'INDIVIDUAL'; + + return ( +
+ {!isParticipant ? ( + } + onClick={handleJoin} + loading={joinMutation.isPending} + disabled={isRegistrationClosed} + > + {isRegistrationClosed ? 'REGISTRATION CLOSED' : 'JOIN HACKATHON'} + + ) : ( +
+ + REGISTERED + + + + +
+ )} + +
+ {!isIndividualOnly && ( + handleTabChange('team-formation')} + icon={} + > + {myTeam ? 'MY TEAM' : 'FIND TEAM'} + + )} + + +
+
+ ); +}; + +export default ActionButtons; diff --git a/app/(landing)/hackathons/[slug]/components/header/Logo.tsx b/app/(landing)/hackathons/[slug]/components/header/Logo.tsx new file mode 100644 index 00000000..3297c84e --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/header/Logo.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Image from 'next/image'; + +const Logo = ({ logo, title }: { logo: string; title: string }) => { + return ( +
+ {logo ? ( + {title + ) : null} +
+ ); +}; + +export default Logo; diff --git a/app/(landing)/hackathons/[slug]/components/header/TitleAndInfo.tsx b/app/(landing)/hackathons/[slug]/components/header/TitleAndInfo.tsx new file mode 100644 index 00000000..72b9ea24 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/header/TitleAndInfo.tsx @@ -0,0 +1,145 @@ +import { Zap, Globe2Icon } from 'lucide-react'; +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, +} from '@/components/ui/avatar'; +import { Separator } from '@/components/ui/separator'; +import type { Hackathon, Participant } from '@/lib/api/hackathons'; +import { ExtendedBadge } from '@/components/hackathons/ExtendedBadge'; + +type HackathonStatus = Hackathon['status']; + +function getStatusLabel(status: HackathonStatus) { + switch (status) { + case 'ACTIVE': + return 'Submissions Open'; + case 'JUDGING': + return 'Judging'; + case 'UPCOMING': + return 'Upcoming'; + case 'COMPLETED': + return 'Completed'; + case 'CANCELLED': + return 'Cancelled'; + case 'ARCHIVED': + return 'Archived'; + default: + return 'Draft'; + } +} + +function isActiveStatus(status: HackathonStatus) { + return status === 'ACTIVE'; +} + +interface TitleAndInfoProps { + title?: string; + status?: HackathonStatus; + participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; + participantCount?: number; + venueType?: 'VIRTUAL' | 'PHYSICAL'; + participants?: Participant[]; + submissionDeadline?: string; + submissionDeadlineOriginal?: string; +} + +const TitleAndInfo = ({ + title = 'Boundless Global Hackathon', + status = 'UPCOMING', + participantType = 'INDIVIDUAL', + participantCount = 0, + venueType = 'VIRTUAL', + participants = [], + submissionDeadline, + submissionDeadlineOriginal, +}: TitleAndInfoProps) => { + const statusLabel = getStatusLabel(status); + const isActive = isActiveStatus(status); + const venueLabel = venueType === 'PHYSICAL' ? 'Physical' : 'Virtual'; + const typeLabel = + participantType === 'TEAM' + ? 'Team' + : participantType === 'TEAM_OR_INDIVIDUAL' + ? 'Team / Individual' + : 'Individual'; + + const displayParticipants = participants.slice(0, 5); + + return ( +
+

+ {title} +

+ +
+ + + {statusLabel} + + + +
+ +
+ + + + {typeLabel} + + +
+ +
+ + + + {venueLabel} + + + {participantCount > 0 && ( + <> +
+ +
+
+ + {displayParticipants.map((participant, i) => ( + + + + {participant.user.profile.name?.[0]?.toUpperCase() || 'U'} + + + ))} + {participantCount > 5 && ( + + +{(participantCount - 5).toLocaleString()} + + )} + +
+ + )} +
+
+ ); +}; + +export default TitleAndInfo; diff --git a/app/(landing)/hackathons/[slug]/components/header/index.tsx b/app/(landing)/hackathons/[slug]/components/header/index.tsx new file mode 100644 index 00000000..3a87b4d3 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/header/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Logo from './Logo'; +import TitleAndInfo from './TitleAndInfo'; +import ActionButtons from './ActionButtons'; +import type { Hackathon } from '@/lib/api/hackathons'; + +interface HeaderProps { + hackathon: Hackathon; +} + +const Header = ({ hackathon }: HeaderProps) => { + return ( +
+
+ + +
+
+ +
+
+ ); +}; + +export default Header; diff --git a/app/(landing)/hackathons/[slug]/components/sidebar/FollowAndMessage.tsx b/app/(landing)/hackathons/[slug]/components/sidebar/FollowAndMessage.tsx new file mode 100644 index 00000000..92cd593d --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/sidebar/FollowAndMessage.tsx @@ -0,0 +1,160 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import Image from 'next/image'; +import { MessageSquare, UserPlus, Check, AlertCircle } from 'lucide-react'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useFollow } from '@/hooks/use-follow'; +import { Skeleton } from '@/components/ui/skeleton'; +import { toast } from 'sonner'; + +export default function FollowAndMessage() { + const { currentHackathon: hackathon } = useHackathonData(); + const hackathonLoading = !hackathon; + const hackathonError = false; // Handled by parent + + const org = hackathon?.organization; + const orgName = org?.name ?? 'Organization'; + const orgLogo = org?.logo ?? ''; + const orgHandle = org?.name + ? `@${org.name.toLowerCase().replace(/\s+/g, '_')}` + : '@organization'; + + const { + isFollowing, + isLoading: followLoading, + toggleFollow, + } = useFollow('ORGANIZATION', org?.id || '', false); + + const handleToggleFollow = async () => { + try { + await toggleFollow(); + toast.success( + isFollowing ? `Unfollowed ${orgName}` : `Following ${orgName}` + ); + } catch (error: any) { + toast.error(error.message || 'Failed to update follow status'); + } + }; + + if (hackathonLoading) { + return ( +
+ +
+ +
+ + +
+
+
+ + +
+
+ ); + } + + if (hackathonError || !org) { + return ( +
+ +

+ Organizer information unavailable +

+
+ ); + } + + return ( +
+
+

+ Organizer +

+
+ +
+
+
+ {orgLogo ? ( + {orgName} + ) : ( + + + {[0, 60, 120, 180, 240, 300].map((angle, i) => { + const rad = (angle * Math.PI) / 180; + const x2 = 28 + 14 * Math.cos(rad); + const y2 = 28 + 14 * Math.sin(rad); + return ( + + + + + ); + })} + + )} +
+
+

{orgName}

+

{orgHandle}

+
+
+ +
+ + ) : ( + + ) + } + iconPosition='left' + onClick={handleToggleFollow} + disabled={followLoading} + className={ + isFollowing + ? 'border-primary/40 bg-primary/10 text-primary cursor-default opacity-80' + : '' + } + > + {isFollowing ? 'Following' : 'Follow'} + + } + iconPosition='left' + > + Message + +
+
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx new file mode 100644 index 00000000..acf45502 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + Clock, + Briefcase, + ChevronRight, + Flower, + AlertCircle, +} from 'lucide-react'; +import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useAuth } from '@/hooks/use-auth'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import Image from 'next/image'; +import { BoundlessButton } from '@/components/buttons'; +import { cn } from '@/lib/utils'; +import { ExtendedBadge } from '@/components/hackathons/ExtendedBadge'; + +function useCountdown(deadline?: string) { + const [timeLeft, setTimeLeft] = useState({ d: 0, h: 0, m: 0, s: 0 }); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!deadline || !isMounted) return; + + const tick = () => { + const diff = new Date(deadline).getTime() - Date.now(); + if (diff <= 0) { + setTimeLeft({ d: 0, h: 0, m: 0, s: 0 }); + return; + } + const d = Math.floor(diff / 86400000); + const h = Math.floor((diff % 86400000) / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + const s = Math.floor((diff % 60000) / 1000); + setTimeLeft({ d, h, m, s }); + }; + + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [deadline, isMounted]); + + return timeLeft; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function PoolAndAction() { + const params = useParams(); + const router = useRouter(); + const { user } = useAuth(); + const slug = params.slug as string; + + const { currentHackathon: hackathon, submissions } = useHackathonData(); + const hackathonLoading = !hackathon; + const hackathonError = false; // Handled by parent + const participantsLoading = false; // Already available in hackathon object + const participants = hackathon?.participants || []; + const deadline = hackathon?.submissionDeadline; + const timeLeft = useCountdown(deadline); + + const totalPool = + hackathon?.prizeTiers.reduce( + (acc, t) => acc + Number(t.prizeAmount || 0), + 0 + ) ?? 0; + + const isLive = hackathon?.status === 'ACTIVE'; + const isEnded = + hackathon?.status === 'COMPLETED' || hackathon?.status === 'JUDGING'; + const currency = hackathon?.prizeTiers[0]?.currency ?? 'USDC'; + const categories = hackathon?.categories ?? []; + + const isParticipant = user + ? participants.some((p: any) => p.userId === user.id) + : false; + + const handleSubmit = () => { + if (!user) { + router.push('/login'); + return; + } + if (!isParticipant) { + // Scroll to registration button or show toast? + // For now, let's just push to submit which should have its own checks + router.push(`/hackathons/${slug}/submit`); + return; + } + router.push(`/hackathons/${slug}/submit`); + }; + + if (hackathonLoading || participantsLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (hackathonError) { + return ( +
+ +

+ Failed to load hackathon +

+

+ Please refresh the page and try again. +

+
+ ); + } + + const getButtonText = () => { + if (!user) return 'Login to Submit'; + if (!isParticipant) return 'Register to Submit'; + if (isEnded) return 'Submissions Closed'; + return 'Submit Now'; + }; + + const isButtonDisabled = isEnded; + + return ( +
+
+ + + + {isLive ? 'Live' : (hackathon?.status ?? 'Upcoming')} + + + {categories.slice(0, 3).map(cat => ( + + {cat} + + ))} +
+ +
+
+
+ +
+
+

+ Total Prize Pool +

+

+ {totalPool.toLocaleString()}{' '} + {currency} +

+
+
+ + {hackathon && hackathon.prizeTiers.length > 0 && ( +
+
+
+ {hackathon.prizeTiers.map((tier, i) => ( +
+ +
+

+ {tier.name ?? + `${i + 1}${['st', 'nd', 'rd'][i] ?? 'th'} Place`} +

+

+ {Number(tier.prizeAmount ?? 0).toLocaleString()}{' '} + + {tier.currency ?? currency} + +

+
+
+ ))} +
+
+ )} + +
+
+

+ Ends In +

+
+ + + {String(timeLeft.d).padStart(2, '0')}d :{' '} + {String(timeLeft.h).padStart(2, '0')}h :{' '} + {String(timeLeft.m).padStart(2, '0')}m :{' '} + {String(timeLeft.s).padStart(2, '0')}s + +
+
+
+
+

+ Submissions +

+
+ + + {hackathon?._count.submissions ?? 0} + +
+
+
+ + + ) + } + > + {getButtonText()} + +
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/components/sidebar/index.tsx b/app/(landing)/hackathons/[slug]/components/sidebar/index.tsx new file mode 100644 index 00000000..40db095f --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/sidebar/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import PoolAndAction from './PoolAndAction'; +import FollowAndMessage from './FollowAndMessage'; + +const Sidebar = () => { + return ( +
+ + +
+ ); +}; + +export default Sidebar; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/Lists.tsx b/app/(landing)/hackathons/[slug]/components/tabs/Lists.tsx new file mode 100644 index 00000000..962a99da --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/Lists.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { LucideIcon } from 'lucide-react'; + +interface TabItem { + id: string; + label: string; + badge?: number; + icon?: LucideIcon; +} + +interface ListsProps { + tabs: TabItem[]; +} + +export default function Lists({ tabs }: ListsProps) { + return ( + // Outer wrapper handles the bottom border + horizontal scroll on mobile +
+ + {tabs.map(({ id, label, badge, icon: Icon }) => ( + + {Icon && } + {label} + {badge !== undefined && badge > 0 && ( + + {badge} + + )} + + ))} + +
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/AnnouncementsTab.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/AnnouncementsTab.tsx new file mode 100644 index 00000000..95c0a6b7 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/AnnouncementsTab.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { TabsContent } from '@/components/ui/tabs'; + +import AnnouncementsIndex from './announcements'; + +const Announcements = () => { + return ( + + + + ); +}; + +export default Announcements; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/Discussions.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/Discussions.tsx new file mode 100644 index 00000000..28038761 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Discussions.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries'; +import { TabsContent } from '@/components/ui/tabs'; +import { HackathonDiscussions } from '@/components/hackathons/discussion/comment'; + +const HackathonDiscussionsTab = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + + if (!hackathon) return null; + + return ( + +
+

Discussions

+

+ Join the conversation, ask questions, and share updates. +

+
+ +
+ ); +}; + +export default HackathonDiscussionsTab; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx new file mode 100644 index 00000000..865e23de --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx @@ -0,0 +1,244 @@ +'use client'; + +import React, { useState } from 'react'; +import { useParams } from 'next/navigation'; +import { + Search, + ChevronDown, + Plus, + Sparkles, + Loader2, + Info, +} from 'lucide-react'; +import { TabsContent } from '@/components/ui/tabs'; +import { + useHackathon, + useMyTeam, + useHackathonTeams, +} from '@/hooks/hackathon/use-hackathon-queries'; +import TeamCard from './teams/TeamCard'; +import MyTeamView from './teams/MyTeamView'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { CreateTeamPostModal } from '@/components/hackathons/team-formation/CreateTeamPostModal'; +import { ContactTeamModal } from '@/components/hackathons/team-formation/ContactTeamModal'; +import { Team } from '@/lib/api/hackathons/teams'; + +const FindTeam = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + const { data: myTeam, isLoading: isMyTeamLoading } = useMyTeam(slug); + + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('All Categories'); + const [roleFilter, setRoleFilter] = useState('Role'); + const [page, setPage] = useState(1); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [selectedTeam, setSelectedTeam] = useState(null); + const [isContactModalOpen, setIsContactModalOpen] = useState(false); + + const { data: teamsResponse, isLoading: isTeamsLoading } = useHackathonTeams( + slug, + { + page, + limit: 12, + search: searchQuery, + openOnly: true, + }, + !!hackathon?.id && hackathon.participantType !== 'INDIVIDUAL' + ); + + const teams = teamsResponse?.data?.teams || []; + + const handleJoin = (team: Team) => { + setSelectedTeam(team); + setIsContactModalOpen(true); + }; + + if (!hackathon) return null; + + const isIndividualOnly = hackathon.participantType === 'INDIVIDUAL'; + + if (myTeam) { + return ( + + + + ); + } + + return ( + + {isIndividualOnly ? ( +
+
+ +
+
+

+ Individual Participants Only +

+

+ This hackathon is for individual builders only. Team formation is + not allowed for this event. +

+
+
+ ) : ( + <> +
+
+

Open Teams

+

+ Find builders to collaborate with on your project. +

+
+ {!myTeam && ( + } + iconPosition='left' + className='h-11 rounded-xl px-6 font-bold' + onClick={() => setIsCreateModalOpen(true)} + > + Create Team + + )} +
+ +
+
+ + { + setSearchQuery(e.target.value); + setPage(1); + }} + className='focus:border-primary/20 h-12 w-full rounded-xl border border-white/5 bg-[#141517]/50 pr-4 pl-12 text-sm text-white placeholder-gray-500 transition-all outline-none' + /> +
+ +
+ + + + + + setCategoryFilter('All Categories')} + > + All Categories + + {hackathon.categories?.map(cat => ( + setCategoryFilter(cat)} + > + {cat} + + ))} + + + + + + + + + setRoleFilter('Role')}> + All Roles + + {[ + 'Frontend', + 'Backend', + 'Smart Contract', + 'UI/UX', + 'Rust', + ].map(role => ( + setRoleFilter(role)} + > + {role} + + ))} + + +
+
+ + {isTeamsLoading || isMyTeamLoading ? ( +
+ +

+ Loading Teams... +

+
+ ) : teams.length > 0 ? ( +
+ {teams.map(team => ( + handleJoin(team)} + /> + ))} +
+ ) : ( +
+
+ +
+
+

+ No Teams Found +

+

+ Be the first to start a revolution! Create a team and invite + builders to join your journey. +

+
+
+ )} + + + + + + )} +
+ ); +}; + +export default FindTeam; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx new file mode 100644 index 00000000..c1861629 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Overview.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries'; +import { TabsContent } from '@/components/ui/tabs'; +import { Info, Target, Clock, Trophy, ChevronRight } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +import { useMarkdown } from '@/hooks/use-markdown'; + +const Overview = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + + const { styledContent, loading: markdownLoading } = useMarkdown( + hackathon?.description || '', + { + breaks: true, + gfm: true, + } + ); + + if (!hackathon) return null; + + const now = new Date(); + + interface RawTimelineItem { + label: string; + date?: string; + description?: string; + } + + interface TimelineItem { + label: string; + date: string; + description?: string; + } + + const rawTimelineItems: RawTimelineItem[] = [ + { + label: 'Registration Opens', + date: hackathon.createdAt, + description: 'Sign up and start brainstorming your project.', + }, + { + label: 'Registration Deadline', + date: hackathon.registrationDeadline, + description: 'Last chance to join and form your team.', + }, + { + label: 'Hackathon Starts', + date: hackathon.startDate, + description: 'The hacking phase begins! Start building.', + }, + { + label: 'Submission Deadline', + date: hackathon.submissionDeadline, + description: 'Final project submission and demo video due.', + }, + { + label: 'Judging Ends', + date: hackathon.judgingDeadline, + description: 'Winners will be announced soon after.', + }, + ]; + + const timelineItems: TimelineItem[] = rawTimelineItems.filter( + (item): item is TimelineItem => !!item.date + ); + + // Determine current active milestone + const getStatus = (itemDate: string, index: number) => { + const d = new Date(itemDate); + const nextItem = timelineItems[index + 1]; + const nextD = nextItem ? new Date(nextItem.date) : null; + + if (now > d && (!nextD || now < nextD)) { + return 'active'; + } + if (now > d) { + return 'completed'; + } + return 'upcoming'; + }; + + return ( + +
+

Overview

+

+ Everything you need to know about this hackathon. +

+
+ + {/* About Section */} +
+
+

About the Hackathon

+
+
+ {markdownLoading ? ( +
+
+ Loading description... +
+ ) : ( + styledContent + )} +
+
+ + {/* Tracks & Focus Areas +
+
+ +

Tracks & Focus Areas

+
+
+ {(hackathon.categories.length > 0 + ? hackathon.categories + : ['DeFi 2.0', 'Infrastructure', 'Tooling', 'Public Goods'] + ).map((track, i) => ( +
+

{track}

+

+ Focus on {track.toLowerCase()} innovations, scalability, and + user-centric decentralized applications. +

+
+ ))} +
+
*/} + + {/* Timeline Section */} +
+
+

Timeline

+
+
+ {/* Vertical Line */} +
+ + {timelineItems.map((item, i) => { + const status = getStatus(item.date, i); + const formattedDate = new Date(item.date).toLocaleDateString( + 'en-US', + { + month: 'long', + day: 'numeric', + year: 'numeric', + } + ); + + return ( +
+
+ {status === 'active' && ( +
+ )} +
+ +
+

+ {item.label} + {status === 'active' && ' (Current)'} +

+

+ {formattedDate} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })} +
+
+ + {/* Prizes Section */} +
+
+

Prizes

+
+
+ {hackathon.prizeTiers.map((tier, i) => ( +
+ {i === 0 && ( + + Top Tier + + )} + +
+ +
+ +

+ {tier.name || + (i === 0 ? '1st Place' : i === 1 ? '2nd Place' : '3rd Place')} +

+
+ + {Number(tier.prizeAmount).toLocaleString()} + + + {tier.currency || 'USDC'} + +
+ + {tier.description && ( +

+ {tier.description} +

+ )} +
+ ))} +
+
+
+ ); +}; + +export default Overview; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/Participants.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/Participants.tsx new file mode 100644 index 00000000..a0393c6c --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Participants.tsx @@ -0,0 +1,203 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import { + useHackathon, + useHackathonParticipants, +} from '@/hooks/hackathon/use-hackathon-queries'; +import { TabsContent } from '@/components/ui/tabs'; +import { ChevronDown, Search, Filter, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import ParticipantCard from './participants/ParticipantCard'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +const Participants = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + + // State for filtering and pagination + const [statusFilter, setStatusFilter] = useState< + 'all' | 'submitted' | 'in_progress' + >('all'); + const [skillFilter, setSkillFilter] = useState('all'); + const [page, setPage] = useState(1); + const limit = 12; + + const { + data: participantsData, + isLoading, + isFetching, + } = useHackathonParticipants(slug, { + page, + limit, + status: statusFilter === 'all' ? undefined : statusFilter, + }); + + if (!hackathon) return null; + + const participants = participantsData?.participants || []; + const totalBuilders = participantsData?.pagination?.total || 0; + const hasNextPage = participantsData?.pagination?.hasNext || false; + + // Mock skills for the filter dropdown + const availableSkills = [ + 'Solidity', + 'Rust', + 'React', + 'TypeScript', + 'Python', + 'Go', + 'Design', + ]; + + const handleLoadMore = () => { + if (hasNextPage) { + setPage(prev => prev + 1); + } + }; + + return ( + + {/* Header with Count and Filters */} +
+
+

Participants

+

+ + {totalBuilders.toLocaleString()} + {' '} + builders competing in {hackathon.name} +

+
+ +
+ {/* Skills Filter */} + + + + + + setSkillFilter('all')}> + All Skills + + {availableSkills.map(skill => ( + setSkillFilter(skill)} + > + {skill} + + ))} + + + + {/* Status Filter */} + + + + + + setStatusFilter('all')}> + All Statuses + + setStatusFilter('submitted')}> + Submitted + + setStatusFilter('in_progress')}> + In Progress + + + +
+
+ + {/* Grid of Participant Cards */} +
+ {isLoading && page === 1 ? ( + Array.from({ length: 6 }).map((_, i) => ( +
+ )) + ) : participants.length > 0 ? ( + participants.map(p => ( + + )) + ) : ( +
+ +

No builders found

+

+ Try adjusting your filters to find more participants. +

+
+ )} +
+ + {/* Load More Button */} + {hasNextPage && ( +
+ +
+ )} + + ); +}; + +export default Participants; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/ResourcesTab.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/ResourcesTab.tsx new file mode 100644 index 00000000..aec05d6e --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/ResourcesTab.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { TabsContent } from '@/components/ui/tabs'; +import { ResourcesList } from './resources/index'; + +const ResourcesTab = () => { + return ( + + + + ); +}; + +export default ResourcesTab; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/Submissions.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/Submissions.tsx new file mode 100644 index 00000000..4b87dcbc --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Submissions.tsx @@ -0,0 +1,246 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import { + useHackathon, + useExploreSubmissions, +} from '@/hooks/hackathon/use-hackathon-queries'; +import { TabsContent } from '@/components/ui/tabs'; +import { + Search, + ChevronDown, + ChevronLeft, + ChevronRight, + Loader2, + Sparkles, +} from 'lucide-react'; +import SubmissionCard from './submissions/SubmissionCard'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +const Submissions = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + + // State for filtering and pagination + const [searchQuery, setSearchQuery] = useState(''); + const [trackFilter, setTrackFilter] = useState('All Projects'); + const [statusFilter, setStatusFilter] = useState('Status'); + const [page, setPage] = useState(1); + const limit = 12; + + const { data: submissionsData, isLoading } = useExploreSubmissions( + hackathon?.id || '', + { + page, + limit, + search: searchQuery, + category: trackFilter === 'All Projects' ? undefined : trackFilter, + }, + !!hackathon?.id + ); + + const submissions = submissionsData?.submissions || []; + const pagination = submissionsData?.pagination || { + total: 0, + totalPages: 0, + hasNext: false, + hasPrev: false, + }; + + if (!hackathon) return null; + + const tracks = ['All Projects', ...(hackathon.categories || [])]; + + const handlePageChange = (newPage: number) => { + setPage(newPage); + // Scroll to top of tab content if needed + }; + + return ( + +
+

Explore Submissions

+

+ Browse projects submitted by our community of builders. +

+
+ + {/* Header with Search and Filters */} +
+ {/* Search Bar */} +
+ + { + setSearchQuery(e.target.value); + setPage(1); // Reset to page 1 on search + }} + className='focus:border-primary/20 h-12 w-full rounded-xl border border-white/5 bg-[#141517] pr-4 pl-12 text-sm text-white placeholder-gray-500 transition-all outline-none focus:bg-[#1a1b1e]' + /> +
+ + {/* Filters */} +
+ + + + + + {tracks.map(track => ( + { + setTrackFilter(track); + setPage(1); + }} + className='cursor-pointer hover:bg-white/5' + > + {track} + + ))} + + + + + + + + + {['Status', 'Submitted', 'Shortlisted'].map(status => ( + setStatusFilter(status)} + className='cursor-pointer hover:bg-white/5' + > + {status} + + ))} + + +
+
+ + {/* Grid of Submission Cards */} + {isLoading ? ( +
+ +

+ Loading Submissions... +

+
+ ) : submissions.length > 0 ? ( + <> +
+ {submissions.map((sub: any) => ( + {}} // TODO: Add navigation to project detail + /> + ))} +
+ + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+ + + {Array.from({ length: Math.min(pagination.totalPages, 5) }).map( + (_, i) => { + // Simplistic pagination display logic + const pageNum = i + 1; + const isActive = pageNum === page; + return ( + + ); + } + )} + + {pagination.totalPages > 5 && ( + ... + )} + + {pagination.totalPages > 5 && ( + + )} + + +
+ )} + + ) : ( +
+
+ +
+
+

+ No Submissions Found +

+

+ Try adjusting your search or filters to find projects. +

+
+
+ )} +
+ ); +}; + +export default Submissions; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/Winners.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/Winners.tsx new file mode 100644 index 00000000..ca81fd16 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Winners.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { TabsContent } from '@/components/ui/tabs'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { MainStageHeader } from './winners/MainStageHeader'; +import { TopWinnerCard } from './winners/TopWinnerCard'; +import { PodiumWinnerCard } from './winners/PodiumWinnerCard'; +import { GeneralWinnerCard } from './winners/GeneralWinnerCard'; +import { Trophy } from 'lucide-react'; + +const Winners = () => { + const { currentHackathon, winners, submissions } = useHackathonData(); + + if (!winners || winners.length === 0) { + return ( + +
+ +

+ Winners Coming Soon +

+

+ The judging phase is still in progress. Check back soon for the + results. +

+
+
+ ); + } + + // Sort winners by rank + const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank); + + const rank1 = sortedWinners.find(w => w.rank === 1); + const podium = sortedWinners.filter(w => w.rank === 2 || w.rank === 3); + const others = sortedWinners.filter(w => w.rank > 3); + + // Helper to find submission for a winner + const getSubmission = (submissionId: string) => { + return submissions.find(s => s._id === submissionId); + }; + + return ( + +
+ + +
+ {rank1 && ( + + )} + + {podium.length > 0 && ( +
+ {podium.map(winner => ( + + ))} +
+ )} + + {others.length > 0 && ( +
+
+
+ + Honorable Mentions + +
+
+ +
+ {others.map(winner => ( + + ))} +
+
+ )} +
+
+ + ); +}; + +export default Winners; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/announcementCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/announcementCard.tsx new file mode 100644 index 00000000..0942b8d1 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/announcementCard.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { format } from 'date-fns'; +import { ChevronRight, Pin } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { HackathonAnnouncement } from '@/lib/api/types'; +import BasicAvatar from '@/components/avatars/BasicAvatar'; +import { Button } from '@/components/ui/button'; +import { useParams, useRouter } from 'next/navigation'; + +interface AnnouncementCardProps { + announcement: HackathonAnnouncement; +} + +export function AnnouncementCard({ announcement }: AnnouncementCardProps) { + const { slug } = useParams<{ slug: string }>(); + const router = useRouter(); + + const handleNavigate = () => { + router.push(`/hackathons/${slug}/announcements/${announcement.id}`); + }; + + // Determine a category label based on content keywords since API doesn't have a direct category field + const getCategoryLabel = (title: string, content: string) => { + const text = (title + ' ' + content).toLowerCase(); + if ( + text.includes('deadline') || + text.includes('requirement') || + text.includes('extension') + ) + return { + label: 'IMPORTANT', + className: 'bg-red-500/10 text-red-500 border-red-500/20', + }; + if ( + text.includes('api') || + text.includes('technical') || + text.includes('endpoint') || + text.includes('dev') + ) + return { + label: 'TECHNICAL', + className: 'bg-lime-500/10 text-lime-500 border-lime-500/20', + }; + if ( + text.includes('mixer') || + text.includes('social') || + text.includes('community') || + text.includes('event') + ) + return { + label: 'EVENT', + className: 'bg-blue-500/10 text-blue-500 border-blue-500/20', + }; + return { + label: 'UPDATE', + className: 'bg-gray-500/10 text-gray-500 border-gray-500/20', + }; + }; + + const category = getCategoryLabel(announcement.title, announcement.content); + + return ( +
+
+
+ + {category.label} + + + {format(new Date(announcement.createdAt), 'MMM d, yyyy')} •{' '} + {format(new Date(announcement.createdAt), 'h:mm aa')} + +
+ {announcement.isPinned && ( + + )} +
+ +
+

+ {announcement.title} +

+

+ {announcement.content} +

+
+ +
+
+ +
+ + +
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/header.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/header.tsx new file mode 100644 index 00000000..624f9625 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/header.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +interface HeaderProps { + activeFilter: string; + onFilterChange: (filter: string) => void; +} + +const filters = [ + { label: 'All', id: 'all' }, + { label: 'Technical', id: 'technical' }, + { label: 'Logistics', id: 'logistics' }, + { label: 'Socials', id: 'socials' }, +]; + +export function AnnouncementsHeader({ + activeFilter, + onFilterChange, +}: HeaderProps) { + return ( +
+
+

Announcements

+

Stay updated with the latest news.

+
+ +
+ {filters.map(filter => ( + + ))} +
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/index.tsx new file mode 100644 index 00000000..f98cad03 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/announcements/index.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import { useHackathonAnnouncements } from '@/hooks/hackathon/use-hackathon-queries'; +import { AnnouncementsHeader } from './header'; +import { AnnouncementCard } from './announcementCard'; +import { Megaphone } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; + +export default function AnnouncementsIndex() { + const { slug } = useParams<{ slug: string }>(); + const { data: announcements, isLoading } = useHackathonAnnouncements(slug); + const [activeFilter, setActiveFilter] = useState('all'); + + const filteredAnnouncements = useMemo(() => { + if (!announcements) return []; + if (activeFilter === 'all') return announcements; + + return announcements.filter(announcement => { + const text = ( + announcement.title + + ' ' + + announcement.content + ).toLowerCase(); + if (activeFilter === 'technical') + return ( + text.includes('api') || + text.includes('technical') || + text.includes('endpoint') + ); + if (activeFilter === 'logistics') + return ( + text.includes('deadline') || + text.includes('requirement') || + text.includes('extension') + ); + if (activeFilter === 'socials') + return ( + text.includes('mixer') || + text.includes('social') || + text.includes('community') + ); + return true; + }); + }, [announcements, activeFilter]); + + return ( +
+ + +
+ {isLoading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+ +
+ + +
+
+ )) + ) : filteredAnnouncements.length > 0 ? ( + filteredAnnouncements.map(announcement => ( + + )) + ) : ( +
+
+ +
+

+ No announcements found +

+

+ We couldn't find any announcements for this category. +

+
+ )} +
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/participants/ParticipantCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/participants/ParticipantCard.tsx new file mode 100644 index 00000000..2c89da99 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/participants/ParticipantCard.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import BasicAvatar from '@/components/avatars/BasicAvatar'; +import { Badge } from '@/components/ui/badge'; +import { BoundlessButton } from '@/components/buttons'; +import { IconMessage } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; + +interface ParticipantCardProps { + name: string; + username: string; + image?: string; + submitted?: boolean; + skills?: string[]; + onViewProfile?: () => void; + onMessage?: () => void; +} + +const ParticipantCard = ({ + name, + username, + image, + submitted, + skills = [], + onViewProfile, + onMessage, +}: ParticipantCardProps) => { + const visibleSkills = skills.slice(0, 3); + const hiddenSkillsCount = skills.length - visibleSkills.length; + + return ( +
+
+ + + {submitted ? 'Submitted' : 'In Progress'} + +
+ +
+ {visibleSkills.map(skill => ( + + {skill} + + ))} + {hiddenSkillsCount > 0 && ( + + +{hiddenSkillsCount} + + )} +
+ +
+ + View Profile + + + + +
+
+ ); +}; + +export default ParticipantCard; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/ResourceCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/ResourceCard.tsx new file mode 100644 index 00000000..e6e295ec --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/ResourceCard.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/lib/utils'; +import { + LucideIcon, + ArrowRight, + ExternalLink, + Settings, + Download, +} from 'lucide-react'; + +export interface ResourceCardProps { + title: string; + description: string; + icon: LucideIcon; + actionText: string; + actionHref?: string; + isComingSoon?: boolean; + type?: 'read' | 'repo' | 'download' | 'watch' | 'config'; +} + +export const ResourceCard = ({ + title, + description, + icon: Icon, + actionText, + actionHref, + isComingSoon = false, + type = 'read', +}: ResourceCardProps) => { + if (isComingSoon) { + return ( +
+
+ +
+
+

{title}

+

+ {description} +

+
+
+ ); + } + + const getActionIcon = () => { + switch (type) { + case 'read': + return ( + + ); + case 'repo': + return ( + + ); + case 'download': + return ; + case 'watch': + return ; + case 'config': + return ; + default: + return ; + } + }; + + return ( + +
+
+ +
+
+

{title}

+

+ {description} +

+
+
+ +
+ {actionText} + {getActionIcon()} +
+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/header.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/header.tsx new file mode 100644 index 00000000..c1876908 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/header.tsx @@ -0,0 +1,44 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface ResourceHeaderProps { + activeTab: string; + setActiveTab: (tab: string) => void; +} + +const tabs = ['All', 'Technical', 'Design', 'Media']; + +export const ResourceHeader = ({ + activeTab, + setActiveTab, +}: ResourceHeaderProps) => { + return ( +
+
+

Developer Resources

+

+ Everything you need to build your project on Boundless. +

+
+ +
+ {tabs.map(tab => ( + + ))} +
+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/index.tsx new file mode 100644 index 00000000..41653179 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/index.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useState } from 'react'; +import { + BookOpen, + Code2, + Palette, + Play, + Server, + Box, + ShieldCheck, + Layers, + Lock, + Calendar, +} from 'lucide-react'; +import { ResourceHeader } from './header'; +import { ResourceCard } from './ResourceCard'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import { cn } from '@/lib/utils'; +import { useParams } from 'next/navigation'; +import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries'; +import { Skeleton } from '@/components/ui/skeleton'; + +// Helper to map API resources to UI format +const mapApiResource = (resource: any) => { + const content = ( + (resource.description || '') + + ' ' + + (resource.file?.name || '') + + ' ' + + (resource.link || '') + ).toLowerCase(); + + const isDoc = + content.includes('doc') || + content.includes('guide') || + content.includes('trustlesswork.com'); + const isSdk = + content.includes('sdk') || + content.includes('api') || + content.includes('git') || + content.includes('repo'); + const isDesign = + content.includes('design') || + content.includes('figma') || + content.includes('brand') || + content.includes('asset'); + const isMedia = + content.includes('video') || + content.includes('tutorial') || + content.includes('youtube') || + content.includes('play'); + + let icon = Box; + let category = 'All'; + let type: 'read' | 'repo' | 'download' | 'watch' | 'config' = 'read'; + let actionText = 'View Resource'; + + if (isDoc) { + icon = BookOpen; + category = 'Technical'; + actionText = 'Read Docs'; + type = 'read'; + } else if (isSdk) { + icon = Code2; + category = 'Technical'; + actionText = 'View Repository'; + type = 'repo'; + } else if (isDesign) { + icon = Palette; + category = 'Design'; + actionText = 'Download Kit'; + type = 'download'; + } else if (isMedia) { + icon = Play; + category = 'Media'; + actionText = 'Watch Now'; + type = 'watch'; + } + + // Deriving title + let title = resource.file?.name; + if (!title && resource.link) { + try { + const url = new URL(resource.link); + title = url.hostname.replace('www.', ''); + if (title.includes('docs.')) { + title = 'Documentation'; + } else if (title.includes('github.com')) { + title = 'GitHub Repository'; + } else if (title.includes('figma.com')) { + title = 'Design Assets'; + } else if (title.includes('youtube.com') || title.includes('youtu.be')) { + title = 'Video Tutorial'; + } + } catch { + title = 'External Resource'; + } + } + + if (!title && resource.description) { + title = resource.description.split('\n')[0].substring(0, 40); + } + + return { + title: title || 'Untitled Resource', + description: + resource.description || + `Access the ${title || 'resource'} via the link below.`, + icon, + actionText, + actionHref: resource.link || resource.file?.url, + type, + category, + }; +}; + +export const ResourcesList = () => { + const { slug } = useParams() as { slug: string }; + const { data: hackathon, isLoading } = useHackathon(slug); + const [activeTab, setActiveTab] = useState('All'); + + if (isLoading) { + return ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ); + } + + const apiResources = hackathon?.resources?.map(mapApiResource) || []; + + // Add "More Coming Soon" if there are fewer than 3 resources + if (apiResources.length < 3) { + apiResources.push({ + title: 'More Coming Soon', + description: 'New resources and tools are being added to help you build.', + icon: Box, + actionText: '', + actionHref: '#', + type: 'read', + category: 'All', + // @ts-ignore - custom property for ResourceCard + isComingSoon: true, + } as any); + } + + const filteredResources = apiResources.filter( + (r: any) => + activeTab === 'All' || r.category === activeTab || r.isComingSoon + ); + + return ( +
+ + +
+ {filteredResources.map((resource: any, idx: number) => ( + + ))} +
+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx new file mode 100644 index 00000000..70beaae9 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { LayoutGrid } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import GroupAvatar from '@/components/avatars/GroupAvatar'; +import BasicAvatar from '@/components/avatars/BasicAvatar'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import type { ExploreSubmissionsResponse } from '@/lib/api/hackathons'; + +interface SubmissionCardProps { + submission: ExploreSubmissionsResponse; + onViewClick?: (id: string) => void; +} + +const SubmissionCard = ({ submission, onViewClick }: SubmissionCardProps) => { + const { + id, + projectName, + description, + category, + participationType = 'INDIVIDUAL', + teamName, + teamMembers = [], + participant, + logo, + } = submission; + + const isTeam = participationType?.toUpperCase() === 'TEAM'; + + const submitterName = isTeam + ? (teamName ?? teamMembers?.[0]?.name ?? 'Unnamed Team') + : (participant?.name ?? 'Anonymous'); + + const submitterAvatar = isTeam + ? (teamMembers?.[0]?.avatar ?? '') + : (participant?.image ?? ''); + + return ( +
+ {/* Project Icon/Logo */} +
+ +
+ + {/* Project Info */} +
+

+ {projectName} +

+

+ {description} +

+ + {/* Tags/Categories */} +
+ + {category} + + + Infrastructure + +
+
+ + {/* Footer: Avatars + View Button */} +
+
+ {isTeam ? ( + m.avatar ?? '')} /> + ) : ( + + )} +
+ + onViewClick?.(id)} + > + View Project + +
+
+ ); +}; + +export default SubmissionCard; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx new file mode 100644 index 00000000..07389f1f --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx @@ -0,0 +1,377 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Users, + UserPlus, + Settings, + LogOut, + Crown, + ShieldCheck, + Briefcase, +} from 'lucide-react'; +import { Team, TeamMember } from '@/lib/api/hackathons/teams'; +import { + useLeaveTeam, + useInviteToTeam, + useInvitationActions, + useTransferLeadership, + useRefreshHackathon, +} from '@/hooks/hackathon/use-hackathon-queries'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import BasicAvatar from '@/components/avatars/BasicAvatar'; +import { useAuth } from '@/hooks/use-auth'; +import { getUserProfileByUsername } from '@/lib/api/auth'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { CreateTeamPostModal } from '@/components/hackathons/team-formation/CreateTeamPostModal'; + +interface MyTeamViewProps { + team: Team; + hackathonSlug: string; +} + +const MyTeamView = ({ team, hackathonSlug }: MyTeamViewProps) => { + const { user } = useAuth(); + const isLeader = team.leader.id === user?.id; + + const leaveMutation = useLeaveTeam(hackathonSlug); + const inviteMutation = useInviteToTeam(hackathonSlug); + const transferMutation = useTransferLeadership(hackathonSlug); + const refresh = useRefreshHackathon(hackathonSlug); + + const [inviteIdentifier, setInviteIdentifier] = useState(''); + const [inviteMessage, setInviteMessage] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + const [verificationError, setVerificationError] = useState( + null + ); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isLeaveDialogOpen, setIsLeaveDialogOpen] = useState(false); + const [isTransferDialogOpen, setIsTransferDialogOpen] = useState(false); + const [selectedMember, setSelectedMember] = useState<{ + id: string; + name: string; + } | null>(null); + + const handleLeave = async () => { + await leaveMutation.mutateAsync(team.id); + setIsLeaveDialogOpen(false); + }; + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteIdentifier) return; + + setIsVerifying(true); + setVerificationError(null); + + try { + // Verify user exists first + const profile = await getUserProfileByUsername(inviteIdentifier); + if (!profile) { + setVerificationError('User not found. Please check the username.'); + setIsVerifying(false); + return; + } + + await inviteMutation.mutateAsync({ + teamId: team.id, + inviteeIdentifier: inviteIdentifier, + message: inviteMessage, + }); + setInviteIdentifier(''); + setInviteMessage(''); + setVerificationError(null); + } catch (err: any) { + setVerificationError( + err.response?.status === 404 + ? 'User not found. Please check the username.' + : 'Failed to verify user. Please try again.' + ); + } finally { + setIsVerifying(false); + } + }; + + const handleTransfer = async () => { + if (!selectedMember) return; + await transferMutation.mutateAsync({ + teamId: team.id, + newLeaderId: selectedMember.id, + }); + setIsTransferDialogOpen(false); + setSelectedMember(null); + }; + + return ( +
+ {/* Team Header */} +
+
+
+ {team.teamName.charAt(0).toUpperCase()} +
+
+

+ {team.teamName} +

+
+ + {team.memberCount} / {team.maxSize} Members + + + + {team.isOpen ? 'Open for Recruitment' : 'Closed'} + +
+

+ {team.description} +

+ + {/* Roles Needed inside Header */} + {team.lookingFor && team.lookingFor.length > 0 && ( +
+ {team.lookingFor.map((role, idx) => ( +
+ {role} +
+ ))} +
+ )} +
+
+ +
+ {isLeader ? ( + setIsEditModalOpen(true)} + > + Edit Team + + ) : ( + setIsLeaveDialogOpen(true)} + loading={leaveMutation.isPending} + > + Leave Team + + )} +
+
+ +
+ {/* Invite Builders Section (Horizontal) */} + {isLeader && ( +
+
+
+
+ +

+ Invite Builders +

+
+
+
+ + { + setInviteIdentifier(e.target.value); + setVerificationError(null); + }} + className='w-full rounded-xl border border-white/10 bg-white/5 p-4 font-mono text-sm text-white placeholder-gray-500 transition-all outline-none focus:border-[#A7F950]/50' + /> + {verificationError && ( +

+ {verificationError} +

+ )} +
+
+ + setInviteMessage(e.target.value)} + className='w-full rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white placeholder-gray-500 transition-all outline-none focus:border-[#A7F950]/50' + /> +
+
+
+ + Send Invitation + +
+
+ )} + + {/* Member list - Full Width */} +
+
+

+ Team Members +

+
+ +
+ {/* Leader Card */} +
+
+
+ +
+
+
+ + Leader + +
+
+
+ {isLeader && ( + + )} +
+ + {/* Other Members */} + {Array.isArray(team.members) && + team.members + .filter( + m => typeof m !== 'string' && m.userId !== team.leader.id + ) + .map((member: any) => ( +
+
+
+
+ +
+
+
+ {isLeader && ( + { + setSelectedMember({ + id: member.userId, + name: member.name, + }); + setIsTransferDialogOpen(true); + }} + loading={transferMutation.isPending} + > + Transfer Lead + + )} +
+ ))} +
+
+
+ + {/* Modals & Dialogs */} + + + + + + Leave Team + + Are you sure you want to leave this team? This action cannot be + undone. + + + + + Cancel + + + Leave Team + + + + + + + + + Transfer Leadership + + Are you sure you want to transfer leadership to{' '} + + {selectedMember?.name} + + ? You will lose leader permissions. + + + + + Cancel + + + Transfer + + + + +
+ ); +}; + +export default MyTeamView; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx new file mode 100644 index 00000000..3ece48d1 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { Team, TeamMember } from '@/lib/api/hackathons/teams'; +import GroupAvatar from '@/components/avatars/GroupAvatar'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; + +interface TeamCardProps { + team: Team; + onJoin?: (team: Team) => void; +} + +const TeamCard = ({ team, onJoin }: TeamCardProps) => { + const { + teamName, + description, + lookingFor = [], + memberCount, + maxSize, + members = [], + isOpen, + } = team; + + const status = isOpen ? 'ACTIVE' : 'CLOSED'; + const category = 'DEFI'; + + return ( +
+
+
+
+ {teamName.charAt(0).toUpperCase()} +
+
+

+ {teamName} +

+
+ + {category} + + + {status} + + + {memberCount}/{maxSize} BUILDERS + +
+
+
+ + onJoin?.(team)} + disabled={!isOpen || memberCount >= maxSize} + > + Join Team + +
+ + {/* Description */} +

+ {description} +

+ + {/* Bottom Section */} +
+

+ ROLES NEEDED +

+
+
+ {lookingFor.slice(0, 3).map((role, idx) => ( + + {role} + + ))} + {lookingFor.length > 3 && ( + + +{lookingFor.length - 3} + + )} + {lookingFor.length === 0 && ( + + Full Team + + )} +
+ m.image ?? '')} + /> +
+
+
+ ); +}; + +export default TeamCard; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx new file mode 100644 index 00000000..4f70f751 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/GeneralWinnerCard.tsx @@ -0,0 +1,33 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { HackathonWinner } from '@/lib/api/hackathons'; +import { SubmissionCardProps } from '@/types/hackathon'; + +interface GeneralWinnerCardProps { + winner: HackathonWinner; + submission?: SubmissionCardProps; +} + +export const GeneralWinnerCard = ({ + winner, + submission, +}: GeneralWinnerCardProps) => { + return ( +
+
+
+ #{winner.rank} +
+
+

+ {winner.projectName} +

+ + {winner.teamName || winner.participants[0]?.username} + +
+
+ +
{winner.prize}
+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/MainStageHeader.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/MainStageHeader.tsx new file mode 100644 index 00000000..c8c1f3af --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/MainStageHeader.tsx @@ -0,0 +1,14 @@ +import { AwardIcon, Star } from 'lucide-react'; + +export const MainStageHeader = () => { + return ( +
+
+ +
+

+ Main Stage Winners +

+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/PodiumWinnerCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/PodiumWinnerCard.tsx new file mode 100644 index 00000000..2471134c --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/PodiumWinnerCard.tsx @@ -0,0 +1,53 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { HackathonWinner } from '@/lib/api/hackathons'; +import { Trophy } from 'lucide-react'; +import { SubmissionCardProps } from '@/types/hackathon'; +import Image from 'next/image'; + +interface PodiumWinnerCardProps { + winner: HackathonWinner; + submission?: SubmissionCardProps; +} + +export const PodiumWinnerCard = ({ + winner, + submission, +}: PodiumWinnerCardProps) => { + return ( +
+
+
+ Rank #{winner.rank} +
+
+
+ +
+ {winner.prize} +
+
+ +

+ {winner.projectName} +

+ +

+ {submission?.description || 'No description provided for this project.'} +

+ +
+ + + + {winner.participants[0]?.username.slice(0, 2).toUpperCase()} + + +
+ + {winner.teamName || winner.participants[0]?.username} + +
+
+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/TopWinnerCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/TopWinnerCard.tsx new file mode 100644 index 00000000..1f81e040 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/winners/TopWinnerCard.tsx @@ -0,0 +1,102 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { HackathonWinner } from '@/lib/api/hackathons'; +import { Trophy } from 'lucide-react'; +import Image from 'next/image'; +import { SubmissionCardProps } from '@/types/hackathon'; +import BasicAvatar from '@/components/avatars/BasicAvatar'; + +interface TopWinnerCardProps { + winner: HackathonWinner; + submission?: SubmissionCardProps; +} + +export const TopWinnerCard = ({ winner, submission }: TopWinnerCardProps) => { + const bannerUrl = submission?.logo || '/images/default-project-banner.png'; // Fallback to logo or default + + return ( +
+
+ {/* Project Visual */} +
+ {submission?.logo ? ( + {winner.projectName} + ) : ( +
+ +
+ )} +
+ + {/* Project Info */} +
+
+
+
+ Rank #1 - GRAND PRIZE +
+

+ {winner.projectName} +

+
+ +
+
+ +
+
+ + {winner.prize} + + + USDC DISTRIBUTED + +
+
+
+ +

+ {submission?.description || + 'No description provided for this project.'} +

+ +
+
+ {winner.participants.map((participant, idx) => ( + + // + // + // + // {participant.username.slice(0, 2).toUpperCase()} + // + // + ))} +
+ {/*
+ + {winner.teamName ? 'Team members' : 'Participant'} + + + {winner.teamName + ? winner.teamName + : winner.participants[0]?.username} + +
*/} +
+
+
+
+ ); +}; diff --git a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx new file mode 100644 index 00000000..33894110 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useSearchParams, useRouter, useParams } from 'next/navigation'; +import { Tabs } from '@/components/ui/tabs'; +import Lists from './Lists'; +import Overview from './contents/Overview'; +import Participants from './contents/Participants'; +import Submissions from './contents/Submissions'; +import Discussions from './contents/Discussions'; +import Announcements from './contents/AnnouncementsTab'; +import Winners from './contents/Winners'; +import ResourcesTab from './contents/ResourcesTab'; +import FindTeam from './contents/FindTeam'; +import { useEffect, useState, useMemo } from 'react'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useHackathonAnnouncements } from '@/hooks/hackathon/use-hackathon-queries'; +import { useCommentSystem } from '@/hooks/use-comment-system'; +import { CommentEntityType } from '@/types/comment'; +import { Megaphone } from 'lucide-react'; + +interface HackathonTabsProps { + sidebar?: React.ReactNode; +} + +const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + const slug = params?.slug as string; + + const { currentHackathon, winners, submissions } = useHackathonData(); + const { data: announcements = [] } = useHackathonAnnouncements(slug, !!slug); + + const { comments: discussionComments } = useCommentSystem({ + entityType: CommentEntityType.HACKATHON, + entityId: currentHackathon?.id || '', + page: 1, + limit: 1, + enabled: !!currentHackathon?.id, + }); + + const [activeTab, setActiveTab] = useState('overview'); + + const hackathonTabs = useMemo(() => { + if (!currentHackathon) return []; + console.log({ currentHackathon }); + const hasParticipants = currentHackathon._count?.participants > 0; + const hasResources = currentHackathon.resources?.length > 0; + const hasWinners = winners && winners.length > 0; + const hasAnnouncements = announcements.length > 0; + + const participantType = currentHackathon.participantType; + const isTeamHackathon = + participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL'; + + const tabs = [ + { id: 'overview', label: 'Overview' }, + ...(hasParticipants + ? [ + { + id: 'participants', + label: 'Participants', + badge: currentHackathon._count.participants, + }, + ] + : []), + ...(hasResources + ? [ + { + id: 'resources', + label: 'Resources', + badge: currentHackathon.resources.length, + }, + ] + : []), + ...(hasAnnouncements + ? [ + { + id: 'announcements', + label: 'Announcements', + badge: announcements.length, + icon: Megaphone, + }, + ] + : []), + { + id: 'submissions', + label: 'Submissions', + badge: submissions.filter(p => p.status === 'Approved').length, + }, + { + id: 'discussions', + label: 'Discussions', + badge: discussionComments.pagination.totalItems || 0, + }, + ]; + + if (isTeamHackathon) { + tabs.push({ + id: 'team-formation', + label: 'Find Team', + }); + } + + if (hasWinners) { + tabs.push({ + id: 'winners', + label: 'Winners', + badge: winners.length, + }); + } + + const tabIdToEnabledKey: Record = { + 'team-formation': 'joinATeamTab', + winners: 'winnersTab', + resources: 'resourcesTab', + participants: 'participantsTab', + announcements: 'announcementsTab', + submissions: 'submissionTab', + discussions: 'discussionTab', + }; + + const enabledTabs = currentHackathon.enabledTabs; + + if (Array.isArray(enabledTabs)) { + const enabledSet = new Set(enabledTabs); + return tabs.filter(tab => { + if (tab.id === 'overview') return true; + const key = tabIdToEnabledKey[tab.id] || tab.id; + console.log(key); + return enabledSet.has(key as any); + }); + } + + return tabs; + }, [ + currentHackathon, + winners, + submissions, + announcements, + discussionComments.pagination.totalItems, + ]); + + useEffect(() => { + if (!currentHackathon) return; + + const tabFromUrl = searchParams.get('tab'); + if (!tabFromUrl) { + setActiveTab('overview'); + return; + } + + if (hackathonTabs.some(tab => tab.id === tabFromUrl)) { + setActiveTab(tabFromUrl); + } else { + setActiveTab('overview'); + const queryParams = new URLSearchParams(searchParams.toString()); + queryParams.set('tab', 'overview'); + router.replace(`?${queryParams.toString()}`, { scroll: false }); + } + }, [searchParams, hackathonTabs, router, currentHackathon]); + + const handleTabChange = (value: string) => { + setActiveTab(value); + const queryParams = new URLSearchParams(searchParams.toString()); + queryParams.set('tab', value); + router.push(`?${queryParams.toString()}`, { scroll: false }); + }; + + const isTabVisible = (tabId: string) => + hackathonTabs.some(t => t.id === tabId); + + return ( + + +
+
+
+ {isTabVisible('overview') && } + {isTabVisible('participants') && } + {isTabVisible('submissions') && } + {isTabVisible('announcements') && } + {isTabVisible('discussions') && } + {isTabVisible('winners') && } + {isTabVisible('resources') && } + {isTabVisible('team-formation') && } +
+
+ {sidebar} +
+
+
+
+ ); +}; + +export default HackathonTabs; diff --git a/app/(landing)/hackathons/[slug]/hackathon-detail-design.md b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md index cdb81ff1..03830250 100644 --- a/app/(landing)/hackathons/[slug]/hackathon-detail-design.md +++ b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md @@ -7,6 +7,7 @@ https://www.figma.com/design/EMNGAQl1SGObXcsoa24krt/Boundless_Project-Details?no This design proposes a cleaner and more professional UI/UX for the hackathon detail page. Included in the Figma file: + - Desktop layout - Mobile layout - Banner / hero placement proposal @@ -23,6 +24,7 @@ The hackathon banner is placed as a full-width hero image at the top of the page The sidebar becomes a compact summary card with key information and actions. Design Goals + - Simpler UI and improved visual hierarchy - Clear primary actions (Join, Submit, View Submission) - Consistent spacing and typography diff --git a/app/(landing)/hackathons/[slug]/page.tsx b/app/(landing)/hackathons/[slug]/page.tsx index 6caeb1d7..f079775a 100644 --- a/app/(landing)/hackathons/[slug]/page.tsx +++ b/app/(landing)/hackathons/[slug]/page.tsx @@ -4,6 +4,10 @@ import { getHackathon } from '@/lib/api/hackathon'; import { generateHackathonMetadata } from '@/lib/metadata'; import { HackathonDataProvider } from '@/lib/providers/hackathonProvider'; import HackathonPageClient from './HackathonPageClient'; +import Banner from './components/Banner'; +import Header from './components/header'; +import HackathonTabs from './components/tabs'; +import Sidebar from './components/sidebar'; interface HackathonPageProps { params: Promise<{ slug: string }>; @@ -41,9 +45,19 @@ export default async function HackathonPage({ params }: HackathonPageProps) { notFound(); } + const hackathon = response.data; + return ( - - + +
+ + +
+
+ +
+ } /> +
); } catch { diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx index f0721551..de2c2b5b 100644 --- a/app/(landing)/hackathons/[slug]/submit/page.tsx +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -2,7 +2,7 @@ import { use, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useHackathon } from '@/hooks/hackathon/use-hackathon-queries'; import { useAuthStatus } from '@/hooks/use-auth'; import { useSubmission } from '@/hooks/hackathon/use-submission'; import { SubmissionFormContent } from '@/components/hackathons/submissions/SubmissionForm'; @@ -22,17 +22,9 @@ export default function SubmitProjectPage({ const resolvedParams = use(params); const hackathonSlug = resolvedParams.slug; - const { - currentHackathon, - loading: hackathonLoading, - setCurrentHackathon, - } = useHackathonData(); - - useEffect(() => { - if (hackathonSlug) { - setCurrentHackathon(hackathonSlug); - } - }, [hackathonSlug, setCurrentHackathon]); + // React Query fetches the hackathon — no manual setCurrentHackathon or useEffect needed. + const { data: currentHackathon, isLoading: hackathonLoading } = + useHackathon(hackathonSlug); const hackathonId = currentHackathon?.id || ''; const orgId = currentHackathon?.organizationId || undefined; diff --git a/app/(landing)/hackathons/layout.tsx b/app/(landing)/hackathons/layout.tsx index 68565993..e4970df6 100644 --- a/app/(landing)/hackathons/layout.tsx +++ b/app/(landing)/hackathons/layout.tsx @@ -17,7 +17,7 @@ export default function HackathonLayout({ return ( - + {children} diff --git a/app/providers.tsx b/app/providers.tsx index 942454cd..08f80060 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,28 +1,46 @@ 'use client'; -import { ReactNode } from 'react'; +import { ReactNode, useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '@/components/providers/auth-provider'; import { SocketProvider } from '@/components/providers/socket-provider'; import { WalletProvider } from '@/components/providers/wallet-provider'; import { MessagesProvider } from '@/components/messages/MessagesProvider'; import { TrustlessWorkProvider } from '@/lib/providers/TrustlessWorkProvider'; import { EscrowProvider } from '@/lib/providers/EscrowProvider'; + interface ProvidersProps { children: ReactNode; } export function Providers({ children }: ProvidersProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + gcTime: 5 * 60 * 1000, // 5 minutes + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }) + ); + return ( - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ); } diff --git a/components/avatars/BasicAvatar.tsx b/components/avatars/BasicAvatar.tsx new file mode 100644 index 00000000..0e3b38d4 --- /dev/null +++ b/components/avatars/BasicAvatar.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; + +const BasicAvatar = ({ + name, + username, + image, +}: { + name: string; + username: string; + image?: string; +}) => { + return ( +
+ + + {name.slice(0, 2).toUpperCase()} + +
+

+ {name} +

+

+ @{username} +

+
+
+ ); +}; + +export default BasicAvatar; diff --git a/components/avatars/GroupAvatar.tsx b/components/avatars/GroupAvatar.tsx new file mode 100644 index 00000000..006d2b2a --- /dev/null +++ b/components/avatars/GroupAvatar.tsx @@ -0,0 +1,41 @@ +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, +} from '@/components/ui/avatar'; + +interface GroupAvatarProps { + members: string[]; +} + +const GroupAvatar = ({ members }: GroupAvatarProps) => { + const showCount = members.length > 3; + const maxVisible = showCount ? 3 : members.length; + const visibleMembers = members.slice(0, maxVisible); + const remainingCount = members.length - maxVisible; + + return ( + + {visibleMembers.map((member, index) => ( + + + + {member.slice(0, 2).toUpperCase()} + + + ))} + {remainingCount > 0 && ( + + +{remainingCount} + + )} + + ); +}; + +export default GroupAvatar; diff --git a/components/common/SharePopover.tsx b/components/common/SharePopover.tsx new file mode 100644 index 00000000..821c143f --- /dev/null +++ b/components/common/SharePopover.tsx @@ -0,0 +1,113 @@ +'use client'; + +import React from 'react'; +import { + PopoverRoot, + PopoverTrigger, + PopoverContent, + PopoverBody, + PopoverHeader, + PopoverButton, +} from '@/components/ui/popover-cult'; +import { + IconShare3, + IconCopy, + IconBrandTwitter, + IconBrandLinkedin, + IconMail, +} from '@tabler/icons-react'; +import { toast } from 'sonner'; + +interface SharePopoverProps { + title?: string; + url?: string; + className?: string; + trigger?: React.ReactNode; +} + +const SharePopover = ({ + title, + url, + className, + trigger, +}: SharePopoverProps) => { + const shareUrl = + url || (typeof window !== 'undefined' ? window.location.href : ''); + const shareTitle = title || 'Check out this hackathon on Boundless!'; + + const handleCopyLink = () => { + navigator.clipboard.writeText(shareUrl); + toast.success('Link copied to clipboard!'); + }; + + const handleTwitterShare = () => { + window.open( + `https://twitter.com/intent/tweet?text=${encodeURIComponent( + shareTitle + )}&url=${encodeURIComponent(shareUrl)}`, + '_blank' + ); + }; + + const handleLinkedinShare = () => { + window.open( + `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent( + shareUrl + )}`, + '_blank' + ); + }; + + const handleEmailShare = () => { + window.location.href = `mailto:?subject=${encodeURIComponent( + shareTitle + )}&body=${encodeURIComponent(shareUrl)}`; + }; + + return ( + + + {trigger || } + + + + + Share Hackathon + + + + + + Copy Link + + + + X (Twitter) + + + + LinkedIn + + + + Email + + + + + ); +}; + +export default SharePopover; diff --git a/components/common/share.tsx b/components/common/share.tsx new file mode 100644 index 00000000..07446e4b --- /dev/null +++ b/components/common/share.tsx @@ -0,0 +1,3 @@ +import SharePopover from './SharePopover'; + +export default SharePopover; diff --git a/components/hackathons/ExtendedBadge.tsx b/components/hackathons/ExtendedBadge.tsx new file mode 100644 index 00000000..6ce3e7f6 --- /dev/null +++ b/components/hackathons/ExtendedBadge.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +interface ExtendedBadgeProps { + submissionDeadline?: string; + submissionDeadlineOriginal?: string; + className?: string; +} + +export const ExtendedBadge = ({ + submissionDeadline, + submissionDeadlineOriginal, + className, +}: ExtendedBadgeProps) => { + const isExtended = + submissionDeadlineOriginal && + submissionDeadline && + new Date(submissionDeadline) > new Date(submissionDeadlineOriginal); + + if (!isExtended) return null; + + return ( + + Extended + + ); +}; diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index cabc7abe..14887ba4 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -638,7 +638,7 @@ const SubmissionFormContent: React.FC = ({ if ( data.participationType === 'TEAM' && myTeam && - myTeam.leaderId !== user?.id + myTeam?.leader?.id !== user?.id ) { toast.error('Only the team leader can submit the project'); return; @@ -898,7 +898,7 @@ const SubmissionFormContent: React.FC = ({
- {myTeam.leaderId !== user?.id && ( + {myTeam?.leader?.id !== user?.id && ( = ({ Team Members:

- {myTeam.members?.map(member => ( + {myTeam.members.map((member, idx) => ( - {member.name}{' '} - {member.userId === myTeam.leaderId && '(Leader)'} + {typeof member === 'string' ? member : member.name}{' '} + {(typeof member === 'string' + ? member === myTeam?.leader?.id + : member.userId === myTeam?.leader?.id) && + '(Leader)'} ))}
@@ -1548,7 +1555,7 @@ const SubmissionFormContent: React.FC = ({ !!( form.watch('participationType') === 'TEAM' && myTeam && - myTeam.leaderId !== user?.id + myTeam?.leader?.id !== user?.id ) } className='bg-[#a7f950] text-black hover:bg-[#8fd93f] disabled:cursor-not-allowed disabled:opacity-50' @@ -1563,7 +1570,7 @@ const SubmissionFormContent: React.FC = ({ isSubmitting || (form.watch('participationType') === 'TEAM' && myTeam && - myTeam.leaderId !== user?.id) + myTeam?.leader?.id !== user?.id) ) } className='bg-[#a7f950] text-black hover:bg-[#8fd93f] disabled:cursor-not-allowed disabled:opacity-50' diff --git a/components/hackathons/team-formation/ContactTeamModal.tsx b/components/hackathons/team-formation/ContactTeamModal.tsx new file mode 100644 index 00000000..748ea743 --- /dev/null +++ b/components/hackathons/team-formation/ContactTeamModal.tsx @@ -0,0 +1,154 @@ +'use client'; + +import React from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import { + Mail, + MessageCircle, + Github, + Globe, + Copy, + ExternalLink, + Check, +} from 'lucide-react'; +import { TeamRecruitmentPost } from '@/lib/api/hackathons/teams'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface ContactTeamModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + team: TeamRecruitmentPost | null; + onTrackContact?: (postId: string) => void; +} + +export function ContactTeamModal({ + open, + onOpenChange, + team, + onTrackContact, +}: ContactTeamModalProps) { + const [copied, setCopied] = useState(false); + + if (!team) return null; + + const { teamName, contactMethod, contactInfo, id } = team; + + const handleCopy = () => { + navigator.clipboard.writeText(contactInfo); + setCopied(true); + toast.success('Contact info copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + onTrackContact?.(id); + }; + + const getIcon = () => { + switch (contactMethod) { + case 'email': + return ; + case 'telegram': + case 'discord': + return ; + case 'github': + return ; + default: + return ; + } + }; + + const getLabel = () => { + switch (contactMethod) { + case 'email': + return 'Email Address'; + case 'telegram': + return 'Telegram Username/Link'; + case 'discord': + return 'Discord Username'; + case 'github': + return 'GitHub Profile'; + default: + return 'Contact Info'; + } + }; + + const isLink = + contactInfo.startsWith('http') || contactInfo.startsWith('https'); + + return ( + + + + + Contact {teamName} + + + Reach out to the team leader to express your interest in joining. + + + +
+
+
+
+ {getIcon()} +
+
+

+ {getLabel()} +

+

+ {contactInfo} +

+
+
+
+ +
+ + {copied ? ( + <> + Copied + + ) : ( + <> + Copy Info + + )} + + + {isLink && ( + { + window.open(contactInfo, '_blank'); + onTrackContact?.(id); + }} + > + Open Link + + )} +
+
+ +
+

+ By contacting this team, your interest will be tracked by the + organizers. +

+
+
+
+ ); +} diff --git a/components/hackathons/team-formation/CreateTeamPostModal.tsx b/components/hackathons/team-formation/CreateTeamPostModal.tsx index 815e13d4..4428ced2 100644 --- a/components/hackathons/team-formation/CreateTeamPostModal.tsx +++ b/components/hackathons/team-formation/CreateTeamPostModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -25,10 +25,12 @@ import { SelectValue, } from '@/components/ui/select'; import BoundlessSheet from '@/components/sheet/boundless-sheet'; +import { BoundlessButton } from '@/components/buttons/BoundlessButton'; import { useTeamPosts } from '@/hooks/hackathon/use-team-posts'; import { toast } from 'sonner'; import { Loader2, Plus, X, Trash2 } from 'lucide-react'; -import { type TeamRecruitmentPost } from '@/lib/api/hackathons'; +import { cn } from '@/lib/utils'; +import { type TeamRecruitmentPost } from '@/lib/api/hackathons/teams'; const roleSchema = z.object({ role: z.string().min(1, 'Role name is required'), @@ -67,6 +69,8 @@ interface CreateTeamPostModalProps { onSuccess?: () => void; } +type Step = 'IDENTITY' | 'ROLES' | 'CONTACT'; + export function CreateTeamPostModal({ open, onOpenChange, @@ -80,6 +84,7 @@ export function CreateTeamPostModal({ autoFetch: false, }); + const [step, setStep] = useState('IDENTITY'); const [skillInputs, setSkillInputs] = useState>({}); const isEditMode = !!initialData; @@ -114,6 +119,7 @@ export function CreateTeamPostModal({ }); } else if (!open) { form.reset(); + setStep('IDENTITY'); setSkillInputs({}); } }, [open, initialData, form]); @@ -129,7 +135,6 @@ export function CreateTeamPostModal({ 'lookingFor', currentRoles.filter((_, i) => i !== index) ); - // Clean up skill input for removed role const newSkillInputs = { ...skillInputs }; delete newSkillInputs[index]; setSkillInputs(newSkillInputs); @@ -163,378 +168,390 @@ export function CreateTeamPostModal({ form.setValue('lookingFor', updatedRoles); }; - const validateContactInfo = (method: string, info: string): boolean => { - if (!info.trim()) return false; - - switch (method) { - case 'email': - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(info); - case 'telegram': - // Accept @username or full URL - return /^@?[a-zA-Z0-9_]+$/.test(info) || info.startsWith('http'); - case 'discord': - // Accept username or invite link - return info.length > 0; - case 'github': - // Accept username or full URL - return ( - /^[a-zA-Z0-9]([a-zA-Z0-9]|-(?![.-])){0,38}$/.test(info) || - info.startsWith('http') - ); - default: - return info.length > 0; + const handleNext = async () => { + let fieldsToValidate: (keyof TeamPostFormData)[] = []; + if (step === 'IDENTITY') { + fieldsToValidate = ['teamName', 'description', 'maxSize']; + } else if (step === 'ROLES') { + fieldsToValidate = ['lookingFor']; + } + + const isValid = await form.trigger(fieldsToValidate); + if (isValid) { + if (step === 'IDENTITY') setStep('ROLES'); + else if (step === 'ROLES') setStep('CONTACT'); } }; + const handleBack = () => { + if (step === 'ROLES') setStep('IDENTITY'); + else if (step === 'CONTACT') setStep('ROLES'); + }; + const onSubmit = async (data: TeamPostFormData) => { try { - if (!validateContactInfo(data.contactMethod, data.contactInfo)) { - toast.error('Invalid contact information for selected method'); - form.setError('contactInfo', { - type: 'manual', - message: 'Invalid contact information', - }); - return; - } - if (isEditMode && initialData) { const updatePayload = { teamName: data.teamName, description: data.description, lookingFor: data.lookingFor.map(r => r.role), isOpen: data.lookingFor.length > 0, - contactInfo: { - method: data.contactMethod, - value: data.contactInfo, - }, + contactMethod: data.contactMethod, + contactInfo: data.contactInfo, }; await updatePost(initialData.id, updatePayload); - toast.success('Team post updated successfully'); } else { const createPayload = { ...data, lookingFor: data.lookingFor.map(r => r.role), - maxSize: data.maxSize, }; await createPost(createPayload); - toast.success('Team post created successfully'); } onOpenChange(false); form.reset(); - setSkillInputs({}); + setStep('IDENTITY'); onSuccess?.(); - } catch { - // Error is already handled in the hook + } catch (err) { + console.error('Failed to save team post:', err); } }; + const steps = [ + { id: 'IDENTITY', label: 'Team Details' }, + { id: 'ROLES', label: 'Roles' }, + { id: 'CONTACT', label: 'Contact' }, + ]; + return ( -
-
-

- {isEditMode ? 'Edit Team Post' : 'Create Team Post'} -

-

- {isEditMode - ? 'Update your team recruitment post' - : 'Advertise your project and find team members'} -

-
- -
-
- - {/* Team Name */} - ( - - Team Name - - - - - 3-100 characters. - - - - )} - /> - - {/* Description */} - ( - - Description - -