diff --git a/app/(landing)/blog/[slug]/not-found.tsx b/app/(landing)/blog/[slug]/not-found.tsx index 0dd90ca8..b21c2cf5 100644 --- a/app/(landing)/blog/[slug]/not-found.tsx +++ b/app/(landing)/blog/[slug]/not-found.tsx @@ -28,10 +28,7 @@ export default function BlogNotFound() { -
@@ -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..e422e1f6 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { BoundlessButton } from '@/components/buttons'; +import { IconUsers, IconUserPlus, IconLogout } from '@tabler/icons-react'; +import { + useHackathon, + useMyTeam, + useJoinHackathon, + useLeaveHackathon, +} from '@/hooks/hackathon/use-hackathon-queries'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useOptionalAuth } from '@/hooks/use-auth'; +import type { Participant } from '@/types/hackathon'; +import SharePopover from '@/components/common/SharePopover'; +import { toast } from 'sonner'; +import { useRequireAuthForAction } from '@/hooks/use-require-auth-for-action'; +import { useHackathonStatus } from '@/hooks/hackathon/use-hackathon-status'; + +const ActionButtons = () => { + const { slug } = useParams<{ slug: string }>(); + const router = useRouter(); + const { user } = useOptionalAuth(); + const { currentHackathon: hackathon, refreshCurrentHackathon } = + useHackathonData(); + const { data: myTeam } = useMyTeam(slug); + const { withAuth } = useRequireAuthForAction(); + + const joinMutation = useJoinHackathon(slug); + const leaveMutation = useLeaveHackathon(slug); + + const { status: hackathonStatus } = useHackathonStatus( + hackathon?.startDate, + hackathon?.submissionDeadline, + hackathon?.status + ); + + const isParticipant = user + ? !!hackathon?.isParticipant || + (hackathon?.participants || []).some( + (p: Participant) => p.userId === user.id + ) + : false; + + const handleJoin = withAuth(async () => { + try { + await joinMutation.mutateAsync(); + await refreshCurrentHackathon(); + toast.success('Successfully joined the hackathon!'); + } catch (error: any) { + toast.error(error?.message || 'Failed to join hackathon'); + } + }); + + const handleLeave = withAuth(async () => { + try { + await leaveMutation.mutateAsync(); + await refreshCurrentHackathon(); + 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()}`); + + 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..63eeda75 --- /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..92785515 --- /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..f4ab07cb --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/sidebar/FollowAndMessage.tsx @@ -0,0 +1,163 @@ +'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 () => { + const nextState = !isFollowing; + try { + await toggleFollow(); + toast.success( + nextState ? `Following ${orgName}` : `Unfollowed ${orgName}` + ); + } catch (err: unknown) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to update follow status'; + toast.error(errorMessage); + } + }; + + if (hackathonLoading) { + return ( +
+ +
+ +
+ + +
+
+
+ + +
+
+ ); + } + + if (hackathonError || !org?.id) { + 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..f45082d0 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/sidebar/PoolAndAction.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + Clock, + Briefcase, + ChevronRight, + Flower, + AlertCircle, +} from 'lucide-react'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useOptionalAuth } from '@/hooks/use-auth'; +import { useRequireAuthForAction } from '@/hooks/use-require-auth-for-action'; +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'; +import { Participant } from '@/lib/api/hackathons'; + +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 } = useOptionalAuth(); + const slug = params.slug as string; + + const { + currentHackathon: hackathon, + submissions, + error, + loading, + } = useHackathonData(); + const hackathonError = error; + const isDataLoading = loading || !hackathon; + 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 now = new Date(); + const isSubmissionClosed = + ['COMPLETED', 'JUDGING', 'ARCHIVED', 'CANCELLED'].includes( + hackathon?.status || '' + ) || (deadline ? now > new Date(deadline) : false); + + const isLive = hackathon?.status === 'ACTIVE' && !isSubmissionClosed; + const isEnded = + ['COMPLETED', 'JUDGING', 'ARCHIVED', 'CANCELLED'].includes( + hackathon?.status || '' + ) || (deadline ? now > new Date(deadline) : false); + const currency = hackathon?.prizeTiers[0]?.currency ?? 'USDC'; + const categories = hackathon?.categories ?? []; + + const isParticipant = user + ? participants.some((p: Participant) => p.userId === user.id) + : false; + + const { withAuth } = useRequireAuthForAction(); + + const handleSubmit = withAuth(() => { + if (isButtonDisabled) return; + router.push(`/hackathons/${slug}/submit`); + }); + + if (isDataLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (hackathonError) { + return ( +
+ +

+ Failed to load hackathon +

+

+ {typeof hackathonError === 'string' + ? hackathonError + : 'Please refresh the page and try again.'} +

+
+ ); + } + + const getButtonText = () => { + if (hackathon?.status === 'ARCHIVED') return 'Hackathon Archived'; + if (hackathon?.status === 'CANCELLED') return 'Hackathon Cancelled'; + if (isEnded) return 'Submissions Closed'; + if (!isParticipant) return 'Register to Submit'; + return 'Submit Now'; + }; + + const isButtonDisabled = + isEnded || + !isParticipant || + ['ARCHIVED', 'CANCELLED'].includes(hackathon?.status || ''); + + 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..d99bc44e --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/sidebar/index.tsx @@ -0,0 +1,16 @@ +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..2aa30023 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/Lists.tsx @@ -0,0 +1,52 @@ +'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..fb10e1fe --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx @@ -0,0 +1,290 @@ +'use client'; + +import React, { useCallback, 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'; +import { useMessages } from '@/components/messages/MessagesProvider'; +import { createConversation } from '@/lib/api/messages'; +import { toast } from 'sonner'; +import type { ApiError } from '@/lib/api/api'; + +const isApiError = (e: unknown): e is ApiError => + e !== null && + typeof e === 'object' && + 'message' in e && + typeof (e as ApiError).message === 'string'; + +const FindTeam = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + const { data: myTeam, isLoading: isMyTeamLoading } = useMyTeam(slug); + const { openMessages } = useMessages(); + + const handleMessageLeader = useCallback( + async (team: Team, trigger: HTMLElement) => { + const leaderId = team.leader?.id; + if (!leaderId) return; + try { + const { conversation } = await createConversation(leaderId); + openMessages({ conversationId: conversation.id, trigger }); + } catch (err) { + const msg = isApiError(err) + ? err.message + : 'Failed to start conversation'; + toast.error(msg); + } + }, + [openMessages] + ); + + 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, + category: + categoryFilter !== 'All Categories' ? categoryFilter : undefined, + role: roleFilter !== 'Role' ? roleFilter : undefined, + 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'); + setPage(1); + }} + > + All Categories + + {hackathon.categories?.map(cat => ( + { + setCategoryFilter(cat); + setPage(1); + }} + > + {cat} + + ))} + + + + + + + + + { + setRoleFilter('Role'); + setPage(1); + }} + > + All Roles + + {[ + 'Frontend', + 'Backend', + 'Smart Contract', + 'UI/UX', + 'Rust', + ].map(role => ( + { + setRoleFilter(role); + setPage(1); + }} + > + {role} + + ))} + + +
+
+ + {isTeamsLoading || isMyTeamLoading ? ( +
+ +

+ Loading Teams... +

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

+ 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..e935ee23 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Participants.tsx @@ -0,0 +1,271 @@ +'use client'; + +import React, { useCallback, 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 { Participant } from '@/types/hackathon/participant'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useMessages } from '@/components/messages/MessagesProvider'; +import { createConversation } from '@/lib/api/messages'; +import { toast } from 'sonner'; +import type { ApiError } from '@/lib/api/api'; + +const isApiError = (e: unknown): e is ApiError => + e !== null && + typeof e === 'object' && + 'message' in e && + typeof (e as ApiError).message === 'string'; + +const Participants = () => { + const { slug } = useParams<{ slug: string }>(); + const { data: hackathon } = useHackathon(slug); + const { openMessages } = useMessages(); + + const handleMessageParticipant = useCallback( + async (userId: string, trigger: HTMLElement) => { + try { + const { conversation } = await createConversation(userId); + openMessages({ conversationId: conversation.id, trigger }); + } catch (err) { + const msg = isApiError(err) + ? err.message + : 'Failed to start conversation'; + toast.error(msg); + } + }, + [openMessages] + ); + + // 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 [accumulatedParticipants, setAccumulatedParticipants] = useState< + Participant[] + >([]); + const limit = 12; + + const { + data: participantsData, + isLoading, + isFetching, + } = useHackathonParticipants(slug, { + page, + limit, + status: statusFilter === 'all' ? undefined : statusFilter, + skill: skillFilter === 'all' ? undefined : skillFilter, + }); + + // Reset page and list when filters change + React.useEffect(() => { + setPage(1); + setAccumulatedParticipants([]); + }, [statusFilter, skillFilter]); + + // Accumulate participants as they are fetched + React.useEffect(() => { + if (participantsData?.participants) { + if (page === 1) { + setAccumulatedParticipants(participantsData.participants); + } else { + setAccumulatedParticipants(prev => [ + ...prev, + ...participantsData.participants, + ]); + } + } + }, [participantsData?.participants, page]); + + // Dynamically derive unique skills from participants + const availableSkills = useMemo(() => { + const skillsSet = new Set(); + // Try to get skills from all participants fetchable (if we had them all) + // For now, we derive from what we have in the current view or mock if empty + accumulatedParticipants.forEach((p: Participant) => { + p.user.profile.skills?.forEach((s: string) => { + skillsSet.add(s); + }); + }); + + if (skillsSet.size === 0) { + return [ + 'Solidity', + 'Rust', + 'React', + 'TypeScript', + 'Python', + 'Go', + 'Design', + ]; + } + return Array.from(skillsSet).sort(); + }, [accumulatedParticipants]); + + if (!hackathon) return null; + + const totalBuilders = participantsData?.pagination?.total || 0; + const hasNextPage = participantsData?.pagination?.hasNext || false; + + 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) => ( +
+ )) + ) : accumulatedParticipants.length > 0 ? ( + accumulatedParticipants.map(p => ( + + handleMessageParticipant(p.userId ?? p.user.id, trigger) + } + /> + )) + ) : ( +
+ +

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..19a8f37a --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/Submissions.tsx @@ -0,0 +1,248 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + useHackathon, + useExploreSubmissions, +} from '@/hooks/hackathon/use-hackathon-queries'; +import { ExploreSubmissionsResponse } from '@/lib/api/hackathons'; +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 router = useRouter(); + 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, + status: statusFilter === 'Status' ? undefined : statusFilter, + }, + !!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); + setPage(1); + }} + className='cursor-pointer hover:bg-white/5' + > + {status} + + ))} + + +
+
+ + {/* Grid of Submission Cards */} + {isLoading ? ( +
+ +

+ Loading Submissions... +

+
+ ) : submissions.length > 0 ? ( + <> +
+ {submissions.map((sub: ExploreSubmissionsResponse) => ( + + ))} +
+ + {/* 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..b1b6be1b --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/participants/ParticipantCard.tsx @@ -0,0 +1,95 @@ +'use client'; + +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[]; + userId?: string; + onMessage?: (trigger: HTMLElement) => void; +} + +const ParticipantCard = ({ + name, + username, + image, + submitted, + skills = [], + userId, + onMessage, +}: ParticipantCardProps) => { + const visibleSkills = skills.slice(0, 3); + const hiddenSkillsCount = skills.length - visibleSkills.length; + + return ( +
+ ); +}; + +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..0c91654e --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/ResourceCard.tsx @@ -0,0 +1,107 @@ +'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': + case 'repo': + return ( + + ); + case 'download': + return ; + case 'watch': + return ; + case 'config': + return ; + default: + return ; + } + }; + + const cardContent = ( + <> +
+
+ +
+
+

{title}

+

+ {description} +

+
+
+ +
+ {actionText} + {getActionIcon()} +
+ + ); + + const containerClassName = + 'group flex flex-col justify-between gap-8 rounded-3xl border border-white/5 bg-[#0D0E10] p-8 transition-all duration-300 hover:border-primary/30 hover:bg-[#141517]'; + + if (actionHref) { + return ( +
+ {cardContent} + + ); + } + + return
{cardContent}
; +}; 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..582a4e74 --- /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..34a3e58b --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/resources/index.tsx @@ -0,0 +1,175 @@ +'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'; +import { type HackathonResourceItem } from '@/lib/api/hackathons'; +import { ResourceCardProps } from './ResourceCard'; + +interface MappedResource extends ResourceCardProps { + category: string; +} + +// Helper to map API resources to UI format +const mapApiResource = (resource: HackathonResourceItem): MappedResource => { + 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: MappedResource[] = + 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', + isComingSoon: true, + }); + } + + const filteredResources = apiResources.filter( + (r: MappedResource) => + activeTab === 'All' || r.category === activeTab || r.isComingSoon + ); + + return ( +
+ + +
+ {filteredResources.map((resource: MappedResource, 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..25fc73ef --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx @@ -0,0 +1,93 @@ +import { LayoutGrid } from 'lucide-react'; +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; +} + +const SubmissionCard = ({ submission }: 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 ?? ''); + + const projectUrl = `/projects/${id}?type=submission`; + + return ( +
+ {/* Project Icon/Logo */} +
+ +
+ + {/* Project Info */} +
+

+ {projectName} +

+

+ {description} +

+ + {/* Tags/Categories */} +
+ + {category} + + {submission.category && ( + + {submission.category} + + )} +
+
+ + {/* Footer: Avatars + View Button */} +
+
+ {isTeam ? ( + m.avatar ?? '')} /> + ) : ( + + )} +
+ + + + 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..0932327a --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx @@ -0,0 +1,393 @@ +'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 { useOptionalAuth } from '@/hooks/use-auth'; +import { useRequireAuthForAction } from '@/hooks/use-require-auth-for-action'; +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 } = useOptionalAuth(); + const isLeader = team.leader.id === user?.id; + + const leaveMutation = useLeaveTeam(hackathonSlug); + const inviteMutation = useInviteToTeam(hackathonSlug); + const transferMutation = useTransferLeadership(hackathonSlug); + const refresh = useRefreshHackathon(hackathonSlug); + const { withAuth } = useRequireAuthForAction(); + + 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 = withAuth(async () => { + await leaveMutation.mutateAsync(team.id); + setIsLeaveDialogOpen(false); + }); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteIdentifier) return; + + setIsVerifying(true); + setVerificationError(null); + + // 1. Verify user exists first + try { + const profile = await getUserProfileByUsername(inviteIdentifier); + if (!profile) { + setVerificationError('User not found. Please check the username.'); + setIsVerifying(false); + return; + } + } catch (err: any) { + setVerificationError( + err.response?.status === 404 + ? 'User not found. Please check the username.' + : 'Failed to verify user. Please try again.' + ); + setIsVerifying(false); + return; + } + + // 2. Send invitation + try { + await inviteMutation.mutateAsync({ + teamId: team.id, + inviteeIdentifier: inviteIdentifier, + message: inviteMessage, + }); + + // Only clear inputs and errors on successful invite + setInviteIdentifier(''); + setInviteMessage(''); + setVerificationError(null); + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + 'Failed to send invitation. Please try again.'; + setVerificationError(errorMessage); + } 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((roleObj, idx) => ( +
+ {typeof roleObj === 'string' ? roleObj : roleObj.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='focus:border-primary/50 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' + /> + {verificationError && ( +

+ {verificationError} +

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

+ Team Members +

+
+ +
+ {/* Leader Card */} +
+
+
+ +
+
+
+ + Leader + +
+
+
+ {isLeader && ( + + )} +
+ + {/* Other Members */} + {Array.isArray(team.members) && + team.members + .filter( + (m): m is TeamMember => + typeof m !== 'string' && m.userId !== team.leader.id + ) + .map(member => ( +
+
+
+
+ +
+
+
+ {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..054fc6d7 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx @@ -0,0 +1,131 @@ +'use client'; + +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'; +import { MessageCircle } from 'lucide-react'; + +interface TeamCardProps { + team: Team; + onJoin?: (team: Team) => void; + onMessageLeader?: (team: Team, trigger: HTMLElement) => void; +} + +const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => { + const { + teamName, + description, + lookingFor = [], + memberCount, + maxSize, + members = [], + isOpen, + } = team; + + const status = isOpen ? 'ACTIVE' : 'CLOSED'; + const category = 'DEFI'; + + const isTeamMember = (member: string | TeamMember): member is TeamMember => { + return typeof member !== 'string'; + }; + + return ( +
+
+
+
+ {teamName.charAt(0).toUpperCase()} +
+
+

+ {teamName} +

+
+ + {category} + + + {status} + + + {memberCount}/{maxSize} BUILDERS + +
+
+
+ +
+ {onMessageLeader && team.leader?.id && ( + onMessageLeader(team, e.currentTarget)} + aria-label='Message team leader' + > + + Message + + )} + onJoin?.(team)} + disabled={!isOpen || memberCount >= maxSize} + > + Join Team + +
+
+ + {/* Description */} +

+ {description} +

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

+ ROLES NEEDED +

+
+
+ {lookingFor.slice(0, 3).map((role, idx) => ( + + {typeof role === 'string' ? role : role.role} + + ))} + {lookingFor.length > 3 && ( + + +{lookingFor.length - 3} + + )} + {lookingFor.length === 0 && ( + + Full Team + + )} +
+ (isTeamMember(m) ? (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..88e37a65 --- /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..2360f4e5 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx @@ -0,0 +1,222 @@ +'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, + loading: generalLoading, + } = useHackathonData(); + const { data: announcements = [], isLoading: announcementsLoading } = + 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 []; + const hasParticipants = currentHackathon._count?.participants > 0; + const hasResources = currentHackathon.resources?.length > 0; + const hasWinners = (winners && winners.length > 0) || generalLoading; + const hasAnnouncements = announcements.length > 0 || announcementsLoading; + + 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; + return enabledSet.has(key); + }); + } + + return tabs; + }, [ + currentHackathon, + winners, + submissions, + discussionComments.pagination.totalItems, + announcements, + announcementsLoading, + generalLoading, + ]); + + useEffect(() => { + if (!currentHackathon) return; + + const tabFromUrl = searchParams.get('tab'); + if (!tabFromUrl) { + setActiveTab('overview'); + return; + } + + if (hackathonTabs.some(tab => tab.id === tabFromUrl)) { + setActiveTab(tabFromUrl); + } else { + // If the tab is not in the list yet, check if it's because we're still loading data + const isKnownTabLoading = + (tabFromUrl === 'announcements' && announcementsLoading) || + (tabFromUrl === 'winners' && generalLoading); + + if (!isKnownTabLoading) { + setActiveTab('overview'); + const queryParams = new URLSearchParams(searchParams.toString()); + queryParams.set('tab', 'overview'); + router.replace(`?${queryParams.toString()}`, { scroll: false }); + } + } + }, [ + searchParams, + hackathonTabs, + router, + currentHackathon, + announcementsLoading, + generalLoading, + ]); + + 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 new file mode 100644 index 00000000..03830250 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md @@ -0,0 +1,32 @@ +Hackathon Detail Page Redesign +Issue: #414 + +Figma Design +https://www.figma.com/design/EMNGAQl1SGObXcsoa24krt/Boundless_Project-Details?node-id=0-1&t=A1ywRcn60Xyw0X6h-1 + +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 +- Redesigned hero section +- Sticky sidebar card +- Tab navigation +- All tab layouts (overview, participants, resources, announcements, submissions, discussions, find team, winners) +- Loading state +- Hackathon not found state + +Banner Placement Proposal +The hackathon banner is placed as a full-width hero image at the top of the page, allowing it to visually represent the hackathon and improve page identity. + +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 +- Better mobile usability +- Professional and product-quality look 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/(landing)/newsletter/confirm/error/page.tsx b/app/(landing)/newsletter/confirm/error/page.tsx index 84de7a0e..edeb961d 100644 --- a/app/(landing)/newsletter/confirm/error/page.tsx +++ b/app/(landing)/newsletter/confirm/error/page.tsx @@ -16,7 +16,7 @@ function Content() {

{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}

- + Back to home diff --git a/app/(landing)/newsletter/confirmed/page.tsx b/app/(landing)/newsletter/confirmed/page.tsx index 67718529..5af76033 100644 --- a/app/(landing)/newsletter/confirmed/page.tsx +++ b/app/(landing)/newsletter/confirmed/page.tsx @@ -6,7 +6,7 @@ export default function NewsletterConfirmedPage() {

Your subscription has been confirmed. Welcome aboard!

- + Back to home diff --git a/app/(landing)/newsletter/unsubscribe/error/page.tsx b/app/(landing)/newsletter/unsubscribe/error/page.tsx index b957980a..d9e7816a 100644 --- a/app/(landing)/newsletter/unsubscribe/error/page.tsx +++ b/app/(landing)/newsletter/unsubscribe/error/page.tsx @@ -15,7 +15,7 @@ function Content() {

{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}

- + Back to home diff --git a/app/(landing)/newsletter/unsubscribed/page.tsx b/app/(landing)/newsletter/unsubscribed/page.tsx index 0d758a3c..fafb3f83 100644 --- a/app/(landing)/newsletter/unsubscribed/page.tsx +++ b/app/(landing)/newsletter/unsubscribed/page.tsx @@ -6,7 +6,7 @@ export default function NewsletterUnsubscribedPage() {

You won't receive any more emails from us.

- + Back to home diff --git a/app/(landing)/org/[slug]/org-profile-client.tsx b/app/(landing)/org/[slug]/org-profile-client.tsx index e5706198..0761e2d3 100644 --- a/app/(landing)/org/[slug]/org-profile-client.tsx +++ b/app/(landing)/org/[slug]/org-profile-client.tsx @@ -63,8 +63,8 @@ export default function OrgProfileClient({ slug }: OrgProfileClientProps) { label: 'Hackathons', value: stats.totalHackathons, icon: Trophy, - color: 'text-[#a7f950]', - bgColor: 'bg-[#a7f950]/10', + color: 'text-primary', + bgColor: 'bg-primary/10', }, { label: 'Bounties', @@ -85,9 +85,9 @@ export default function OrgProfileClient({ slug }: OrgProfileClientProps) { return (
{/* Banner / Header */} -
-
-
+
+
+
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index 832add8f..b5a17ef1 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -53,6 +53,7 @@ const ParticipantsPage: React.FC = () => { const hackathonId = params.hackathonId as string; const [view, setView] = useState<'table' | 'grid'>('table'); + const [pageSize, setPageSize] = useState(PAGE_SIZE); const [filters, setFilters] = useState({ search: '', status: 'all', @@ -63,9 +64,9 @@ const ParticipantsPage: React.FC = () => { () => ({ organizationId, autoFetch: false, - pageSize: PAGE_SIZE, // Grid looks better with multiples of 3/4 + pageSize, // Use dynamic page size }), - [organizationId] + [organizationId, pageSize] ); const { @@ -110,7 +111,7 @@ const ParticipantsPage: React.FC = () => { fetchParticipants( actualHackathonId, 1, - PAGE_SIZE, + pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -120,6 +121,7 @@ const ParticipantsPage: React.FC = () => { debouncedSearch, filters.status, filters.type, + pageSize, ]); // Statistics @@ -161,12 +163,12 @@ const ParticipantsPage: React.FC = () => { }, [organizationId, actualHackathonId]); // Handlers - const handlePageChange = (page: number) => { + const handlePageChange = (page: number, limit?: number) => { if (actualHackathonId) { fetchParticipants( actualHackathonId, page, - PAGE_SIZE, + limit ?? pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -220,7 +222,7 @@ const ParticipantsPage: React.FC = () => { fetchParticipants( actualHackathonId, participantsPagination.currentPage, - PAGE_SIZE, + pageSize, mapFiltersToParams(filters, debouncedSearch) ); } @@ -241,16 +243,17 @@ const ParticipantsPage: React.FC = () => { }, onPaginationChange: updater => { if (typeof updater === 'function') { - const newState = ( - updater as (old: { pageIndex: number; pageSize: number }) => { - pageIndex: number; - pageSize: number; - } - )({ + const newState = updater({ pageIndex: participantsPagination.currentPage - 1, pageSize: participantsPagination.itemsPerPage, }); - handlePageChange(newState.pageIndex + 1); + + if (newState.pageSize !== participantsPagination.itemsPerPage) { + setPageSize(newState.pageSize); + handlePageChange(1, newState.pageSize); + } else { + handlePageChange(newState.pageIndex + 1); + } } }, }); diff --git a/app/(landing)/privacy/PrivacyContent.tsx b/app/(landing)/privacy/PrivacyContent.tsx index 0070705d..f0958e37 100644 --- a/app/(landing)/privacy/PrivacyContent.tsx +++ b/app/(landing)/privacy/PrivacyContent.tsx @@ -193,7 +193,7 @@ const PrivacyContent = () => { placeholder='Search keyword' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} - className='w-full rounded-lg border border-[#2B2B2B] bg-[#101010] py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-gray-500 focus:border-[#A7F950] focus:ring-1 focus:ring-[#A7F950] focus:outline-none' + className='focus:border-primary focus:ring-primary w-full rounded-lg border border-[#2B2B2B] bg-[#101010] py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-gray-500 focus:ring-1 focus:outline-none' />
@@ -210,7 +210,7 @@ const PrivacyContent = () => { onClick={() => scrollToSection(item.id)} className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors hover:bg-[#1a1a1a] ${ activeSection === item.id - ? 'bg-[#1a1a1a] text-[#A7F950]' + ? 'text-primary bg-[#1a1a1a]' : 'text-gray-300' }`} > @@ -841,14 +841,14 @@ const PrivacyContent = () => {
collins@boundlessfi.xyz https://boundlessfi.xyz diff --git a/app/(landing)/privacy/TermsContent.tsx b/app/(landing)/privacy/TermsContent.tsx index 71abd96c..ac2b6a18 100644 --- a/app/(landing)/privacy/TermsContent.tsx +++ b/app/(landing)/privacy/TermsContent.tsx @@ -192,7 +192,7 @@ const TermsContent = () => { placeholder='Search keyword' value={searchQuery} onChange={e => setSearchQuery(e.target.value)} - className='w-full rounded-lg border border-[#2B2B2B] bg-[#101010] py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-gray-500 focus:border-[#A7F950] focus:ring-1 focus:ring-[#A7F950] focus:outline-none' + className='focus:border-primary focus:ring-primary w-full rounded-lg border border-[#2B2B2B] bg-[#101010] py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-gray-500 focus:ring-1 focus:outline-none' />
@@ -209,7 +209,7 @@ const TermsContent = () => { onClick={() => scrollToSection(item.id)} className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors hover:bg-[#1a1a1a] ${ activeSection === item.id - ? 'bg-[#1a1a1a] text-[#A7F950]' + ? 'text-primary bg-[#1a1a1a]' : 'text-gray-300' }`} > @@ -484,14 +484,14 @@ const TermsContent = () => {
collins@boundlessfi.xyz https://boundlessfi.xyz diff --git a/app/dashboard/page copy.tsx b/app/dashboard/page copy.tsx deleted file mode 100644 index 7546289b..00000000 --- a/app/dashboard/page copy.tsx +++ /dev/null @@ -1,225 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { - Loader2, - LogOut, - User, - Mail, - Shield, - CheckCircle2, -} from 'lucide-react'; -import { authClient } from '@/lib/auth-client'; -import { useAuthActions } from '@/hooks/use-auth'; - -export default function DashboardPage() { - const { data: session, isPending: sessionPending } = authClient.useSession(); - const router = useRouter(); - const { logout } = useAuthActions(); - - useEffect(() => { - if (!sessionPending && !session?.user) { - router.push('/auth?mode=signin'); - } - }, [session, sessionPending, router]); - - const handleSignOut = async () => { - await logout(); - router.push('/'); - }; - - if (sessionPending) { - return ( -
-
- - Loading... -
-
- ); - } - - if (!session?.user) { - return null; - } - - return ( -
-
- {/* Header */} -
-
-

Dashboard

-

- Welcome back, {session.user.name || session.user.email} -

-
- -
- - {/* Stats Cards Grid */} -
- {/* Profile Card */} - -
- - -
- -
- Profile -
-
- -
- - - - {session.user.name?.charAt(0) || - session.user.email.charAt(0)} - - -
-

- {session.user.name || 'No name'} -

-

- {session.user.email} -

-
-
-
-
- - {/* Account Details Card */} - -
- - -
- -
- Account Details -
-
- -
- User ID - - {session.user.id} - -
-
- Email - - {session.user.email} - -
-
-
- - {/* Status Card */} - -
- - -
- -
- Status & Verification -
-
- -
- Email Status -
- {session.user.emailVerified ? ( - <> - - - Verified - - - ) : ( - - Unverified - - )} -
-
-
- Account Status -
-
- - Active - -
-
- {(session.user as { lastLoginMethod?: string | null }) - ?.lastLoginMethod && ( -
- - Last Login Method - - - {(() => { - const method = ( - session.user as { lastLoginMethod?: string | null } - ).lastLoginMethod; - return method === 'google' - ? 'Google' - : method === 'email' - ? 'Email' - : method || 'N/A'; - })()} - -
- )} -
-
-
- - {/* Welcome Card */} -
- -
-
- - - Welcome to Boundless - - - Your platform for crowdfunding and grants - - - -

- This is your dashboard where you can manage your projects, view - contributions, and access all the features of the platform. The - authentication system is now working properly! -

-
-
-
-
-
- ); -} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx deleted file mode 100644 index 69a6c55d..00000000 --- a/app/dashboard/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { DashboardContent } from '@/components/dashboard-content'; -import React from 'react'; - -export default function Page() { - return ; -} diff --git a/app/me/crowdfunding/[slug]/edit/components/TeamSection.tsx b/app/me/crowdfunding/[slug]/edit/components/TeamSection.tsx index b8369c36..48236482 100644 --- a/app/me/crowdfunding/[slug]/edit/components/TeamSection.tsx +++ b/app/me/crowdfunding/[slug]/edit/components/TeamSection.tsx @@ -120,8 +120,8 @@ const CollapsibleTeamMember = ({ className={cn( 'rounded-lg border px-3 py-2 text-sm transition-all', member.role === role.value - ? 'border-[#A7F950] bg-[#A7F95014] text-[#A7F950]' - : 'border-[#2B2B2B] bg-[#0A0A0A] text-white hover:border-[#A7F950]/50' + ? 'border-primary text-primary bg-[#A7F95014]' + : 'hover:border-primary/50 border-[#2B2B2B] bg-[#0A0A0A] text-white' )} > {role.label} diff --git a/app/me/hackathons/submissions/page.tsx b/app/me/hackathons/submissions/page.tsx index 767c32af..8d079f34 100644 --- a/app/me/hackathons/submissions/page.tsx +++ b/app/me/hackathons/submissions/page.tsx @@ -258,7 +258,7 @@ const SubmissionsPage: FC = () => { diff --git a/components/avatars/BasicAvatar.tsx b/components/avatars/BasicAvatar.tsx new file mode 100644 index 00000000..d827905a --- /dev/null +++ b/components/avatars/BasicAvatar.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; +import { cn } from '@/lib/utils'; + +const BasicAvatar = ({ + name, + username, + image, + truncate = true, +}: { + name: string; + username: string; + image?: string; + truncate?: boolean; +}) => { + 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/bounties/BountyComments.tsx b/components/bounties/BountyComments.tsx index 9907b171..652e4c81 100644 --- a/components/bounties/BountyComments.tsx +++ b/components/bounties/BountyComments.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useAuth } from '@/hooks/use-auth'; +import { useOptionalAuth } from '@/hooks/use-auth'; import { GenericCommentThread } from '@/components/comments/GenericCommentThread'; import { useCommentSystem } from '@/hooks/use-comment-system'; import { CommentEntityType } from '@/types/comment'; @@ -10,7 +10,7 @@ interface BountyCommentsProps { } export function BountyComments({ bountyId }: BountyCommentsProps) { - const { user } = useAuth(); + const { user } = useOptionalAuth(); // Initialize the comment system for this bounty const commentSystem = useCommentSystem({ diff --git a/components/common/SharePopover.tsx b/components/common/SharePopover.tsx new file mode 100644 index 00000000..b425ffca --- /dev/null +++ b/components/common/SharePopover.tsx @@ -0,0 +1,111 @@ +'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 = () => { + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( + shareTitle + )}&url=${encodeURIComponent(shareUrl)}`; + const newWindow = window.open(twitterUrl, '_blank', 'noopener,noreferrer'); + if (newWindow) newWindow.opener = null; + }; + + const handleLinkedinShare = () => { + const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent( + shareUrl + )}`; + const newWindow = window.open(linkedinUrl, '_blank', 'noopener,noreferrer'); + if (newWindow) newWindow.opener = null; + }; + + 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/crowdfunding/campaign-comments-tab.tsx b/components/crowdfunding/campaign-comments-tab.tsx index 80779a9f..75dab824 100644 --- a/components/crowdfunding/campaign-comments-tab.tsx +++ b/components/crowdfunding/campaign-comments-tab.tsx @@ -12,7 +12,7 @@ import { Comment as CommentType, ReportReason, } from '@/types/comment'; -import { useAuth } from '@/hooks/use-auth'; +import { useOptionalAuth } from '@/hooks/use-auth'; import { reportError } from '@/lib/error-reporting'; import { Loader2, MessageCircle } from 'lucide-react'; @@ -26,7 +26,7 @@ export function CampaignCommentsTab({ campaignId }: CampaignCommentsTabProps) { >('createdAt'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - const { user } = useAuth(false); + const { user } = useOptionalAuth(); // Use the generic comment system const { diff --git a/components/dashboard-content.tsx b/components/dashboard-content.tsx index a8dcd2c5..2aba3177 100644 --- a/components/dashboard-content.tsx +++ b/components/dashboard-content.tsx @@ -7,7 +7,7 @@ import { SectionCards } from '@/components/section-cards'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { useAuthStatus } from '@/hooks/use-auth'; -import data from '../app/dashboard/data.json'; +import data from '../data/data.json'; import React, { useState } from 'react'; import { FamilyWalletButton } from '@/components/wallet/FamilyWalletButton'; import { diff --git a/components/escrow/FundEscrowButton.tsx b/components/escrow/FundEscrowButton.tsx index 54652343..1ea7f8d1 100644 --- a/components/escrow/FundEscrowButton.tsx +++ b/components/escrow/FundEscrowButton.tsx @@ -15,6 +15,10 @@ import { } from '@trustless-work/escrow'; import { toast } from 'sonner'; import { reportError } from '@/lib/error-reporting'; +import { useWalletProtection } from '@/hooks/use-wallet-protection'; +import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; +import WalletNotReadyModal from '@/components/wallet/WalletNotReadyModal'; +import { WalletSheet } from '@/components/wallet/WalletSheet'; // Extended type to include balance property that may exist at runtime // Using intersection type to avoid type conflicts with required balance property @@ -34,7 +38,6 @@ import { * Component to fund an existing multi-release escrow using Trustless Work */ export const FundEscrowButton = () => { - const { walletAddress } = useWalletContext(); const { contractId, escrow, updateEscrow } = useEscrowContext(); const { fundEscrow } = useFundEscrow(); const { sendTransaction } = useSendTransaction(); @@ -45,6 +48,22 @@ export const FundEscrowButton = () => { transactionHash?: string; } | null>(null); + // Wallet protection hook + const { + requireWallet, + showWalletModal, + showNotReadyModal, + notReadyReasons, + handleWalletConnected, + closeWalletModal, + closeNotReadyModal, + publicKey: walletAddress, + } = useWalletProtection({ + actionName: 'fund escrow', + }); + + const [isWalletDrawerOpen, setIsWalletDrawerOpen] = useState(false); + // Calculate total amount from all milestones const calculateTotalAmount = (): number => { if (!escrow || !escrow.milestones) { @@ -94,79 +113,86 @@ export const FundEscrowButton = () => { ); } - // Step 1: Prepare the payload according to FundEscrowPayload type - const payload: FundEscrowPayload = { - contractId: contractId, - signer: walletAddress, - amount: totalAmount, - }; - - // Log payload for debugging - // Step 2: Execute function from Trustless Work - const fundResponse: EscrowRequestResponse = await fundEscrow( - payload, - 'multi-release' as EscrowType - ); + const walletReady = await requireWallet(async () => { + // Step 1: Prepare the payload according to FundEscrowPayload type + const payload: FundEscrowPayload = { + contractId: contractId, + signer: walletAddress!, + amount: totalAmount, + }; - // Type guard: Check if response is successful - if ( - fundResponse.status !== ('SUCCESS' as Status) || - !fundResponse.unsignedTransaction - ) { - const errorMessage = - 'message' in fundResponse && typeof fundResponse.message === 'string' - ? fundResponse.message - : 'Failed to fund escrow'; - throw new Error(errorMessage); - } + // Step 2: Execute function from Trustless Work + const fundResponse: EscrowRequestResponse = await fundEscrow( + payload, + 'multi-release' as EscrowType + ); - const { unsignedTransaction } = fundResponse; + // Type guard: Check if response is successful + if ( + fundResponse.status !== ('SUCCESS' as Status) || + !fundResponse.unsignedTransaction + ) { + const errorMessage = + 'message' in fundResponse && + typeof fundResponse.message === 'string' + ? fundResponse.message + : 'Failed to fund escrow'; + throw new Error(errorMessage); + } - // Step 3: Sign transaction with wallet - const signedXdr = await signTransaction({ - unsignedTransaction, - address: walletAddress, - }); + const { unsignedTransaction } = fundResponse; + + // Step 3: Sign transaction with wallet + const signedXdr = await signTransaction({ + unsignedTransaction, + address: walletAddress!, + }); + + // Step 4: Send transaction + const sendResponse = await sendTransaction(signedXdr); + + // Type guard: Check if response is successful + if ( + 'status' in sendResponse && + sendResponse.status !== ('SUCCESS' as Status) + ) { + const errorMessage = + 'message' in sendResponse && + typeof sendResponse.message === 'string' + ? sendResponse.message + : 'Failed to send transaction'; + throw new Error(errorMessage); + } - // Step 4: Send transaction - const sendResponse = await sendTransaction(signedXdr); + // Update escrow balance in context + if (escrow) { + const escrowWithBalance = escrow as MultiReleaseEscrowWithBalance; + const currentBalance = escrowWithBalance.balance || 0; + const updatedEscrow: MultiReleaseEscrowWithBalance = { + ...escrow, + balance: currentBalance + totalAmount, + }; + updateEscrow(updatedEscrow as MultiReleaseEscrow); + } - // Type guard: Check if response is successful - if ( - 'status' in sendResponse && - sendResponse.status !== ('SUCCESS' as Status) - ) { - const errorMessage = + // Display success status + const successMessage = 'message' in sendResponse && typeof sendResponse.message === 'string' ? sendResponse.message - : 'Failed to send transaction'; - throw new Error(errorMessage); - } + : 'Escrow funded successfully!'; - // Update escrow balance in context - if (escrow) { - // Balance may not be in the type, so we use type assertion - const escrowWithBalance = escrow as MultiReleaseEscrowWithBalance; - const currentBalance = escrowWithBalance.balance || 0; - const updatedEscrow: MultiReleaseEscrowWithBalance = { - ...escrow, - balance: currentBalance + totalAmount, - }; - updateEscrow(updatedEscrow as MultiReleaseEscrow); - } + setFundingStatus({ + success: true, + message: successMessage, + }); - // Display success status - const successMessage = - 'message' in sendResponse && typeof sendResponse.message === 'string' - ? sendResponse.message - : 'Escrow funded successfully!'; + toast.success('Escrow funded successfully!'); + }, totalAmount); - setFundingStatus({ - success: true, - message: successMessage, - }); - - toast.success('Escrow funded successfully!'); + if (!walletReady) { + setIsLoading(false); + return; + } } catch (err) { reportError(err, { context: 'escrow-fund' }); setFundingStatus({ @@ -187,147 +213,159 @@ export const FundEscrowButton = () => { const totalAmount = calculateTotalAmount(); - if (fundingStatus) { - return ( - - -
- {fundingStatus.success ? ( - - ) : ( - - )} - + {fundingStatus ? ( + + +
+ {fundingStatus.success ? ( + + ) : ( + + )} + + {fundingStatus.success + ? 'Escrow Funded Successfully!' + : 'Funding Failed'} + +
+ - {fundingStatus.success - ? 'Escrow Funded Successfully!' - : 'Funding Failed'} -
-
- - {fundingStatus.message} - -
- {fundingStatus.success && escrow && ( - -
-
- - Previous Balance: - - - {formatAmount( - ((escrow as MultiReleaseEscrowWithBalance).balance || 0) - - totalAmount - )} - -
-
- - Funded Amount: - - - +{formatAmount(totalAmount)} - -
-
- - New Balance: - - - {formatAmount( - (escrow as MultiReleaseEscrowWithBalance).balance || 0 - )} - + {fundingStatus.message} + + + {fundingStatus.success && escrow && ( + +
+
+ + Previous Balance: + + + {formatAmount( + ((escrow as MultiReleaseEscrowWithBalance).balance || 0) - + totalAmount + )} + +
+
+ + Funded Amount: + + + +{formatAmount(totalAmount)} + +
+
+ + New Balance: + + + {formatAmount( + (escrow as MultiReleaseEscrowWithBalance).balance || 0 + )} + +
+ +
+ )} + + ) : !contractId || !escrow ? ( +
+

+ Please initialize an escrow first before funding. +

+
+ ) : !escrow.milestones || escrow.milestones.length === 0 ? ( +
+

+ Error: Escrow initialized without milestones +

+

+ This escrow was initialized without milestones. Please initialize a + new escrow with milestones. +

+
+ ) : ( + <> +
+
+ + Total Funding Amount: + + + {formatAmount(totalAmount)} +
- - - )} - - ); - } - - if (!contractId || !escrow) { - return ( -
-

- Please initialize an escrow first before funding. -

-
- ); - } - - // Check if escrow has milestones - if (!escrow.milestones || escrow.milestones.length === 0) { - return ( -
-

- Error: Escrow initialized without milestones -

-

- This escrow was initialized without milestones. Please initialize a - new escrow with milestones. -

-
- ); - } +

+ This amount is the sum of all milestone amounts ( + {escrow.milestones.length} milestones) +

+
- return ( -
-
-
- - Total Funding Amount: - - - {formatAmount(totalAmount)} - -
-

- This amount is the sum of all milestone amounts ( - {escrow.milestones.length} milestones) -

-
- - + + + )} + + {/* Wallet Modals */} + + + setIsWalletDrawerOpen(true)} + actionName='fund escrow' + /> + +
); }; 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/HackathonComments.tsx b/components/hackathons/HackathonComments.tsx index 62ea783e..b0d08eca 100644 --- a/components/hackathons/HackathonComments.tsx +++ b/components/hackathons/HackathonComments.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useAuth } from '@/hooks/use-auth'; +import { useOptionalAuth } from '@/hooks/use-auth'; import { GenericCommentThread } from '@/components/comments/GenericCommentThread'; import { useCommentSystem } from '@/hooks/use-comment-system'; import { CommentEntityType } from '@/types/comment'; @@ -10,9 +10,8 @@ interface HackathonCommentsProps { } export function HackathonComments({ hackathonId }: HackathonCommentsProps) { - const { user } = useAuth(); + const { user } = useOptionalAuth(); - // Initialize the comment system for this hackathon const commentSystem = useCommentSystem({ entityType: CommentEntityType.HACKATHON, entityId: hackathonId, @@ -21,7 +20,6 @@ export function HackathonComments({ hackathonId }: HackathonCommentsProps) { enabled: true, }); - // Current user info for the comment system const currentUser = user ? { id: user.id, diff --git a/components/hackathons/HackathonsPageHero.tsx b/components/hackathons/HackathonsPageHero.tsx index 3790f435..418db5a3 100644 --- a/components/hackathons/HackathonsPageHero.tsx +++ b/components/hackathons/HackathonsPageHero.tsx @@ -65,7 +65,7 @@ export default function HackathonsPageHero() { Start Exploring Hackathons diff --git a/components/hackathons/discussion/comment.tsx b/components/hackathons/discussion/comment.tsx index 0865cca7..d29855d3 100644 --- a/components/hackathons/discussion/comment.tsx +++ b/components/hackathons/discussion/comment.tsx @@ -156,7 +156,7 @@ export function HackathonDiscussions({ if (loading) return (
- + Loading discussions...
); @@ -169,7 +169,7 @@ export function HackathonDiscussions({

diff --git a/components/hackathons/hackathonBanner.tsx b/components/hackathons/hackathonBanner.tsx index f08d27b0..b5623192 100644 --- a/components/hackathons/hackathonBanner.tsx +++ b/components/hackathons/hackathonBanner.tsx @@ -30,6 +30,7 @@ interface HackathonBannerProps { onLeaveClick?: () => void; isLeaving?: boolean; participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; + submissionDeadlineExtendedAt?: string | null; } export function HackathonBanner({ @@ -46,15 +47,18 @@ export function HackathonBanner({ registrationDeadline, isLeaving, participantType, + submissionDeadlineExtendedAt, onJoinClick, onSubmitClick, onViewSubmissionClick, onFindTeamClick, onLeaveClick, + status: backendStatus, }: HackathonBannerProps) { const { status, timeRemaining, formatCountdown } = useHackathonStatus( startDate, - deadline + deadline, + backendStatus ); const { isAuthenticated } = useAuthStatus(); const router = useRouter(); @@ -170,7 +174,7 @@ export function HackathonBanner({ return ( ); diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx index 9a4a9c2a..b2b1e897 100644 --- a/components/hackathons/hackathonStickyCard.tsx +++ b/components/hackathons/hackathonStickyCard.tsx @@ -34,6 +34,8 @@ interface HackathonStickyCardProps { onFindTeamClick?: () => void; onLeaveClick?: () => void; participantType?: 'INDIVIDUAL' | 'TEAM' | 'TEAM_OR_INDIVIDUAL'; + submissionDeadlineExtendedAt?: string | null; + status?: string; } export function HackathonStickyCard(props: HackathonStickyCardProps) { @@ -54,9 +56,11 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { onLeaveClick, isLeaving, participantType, + submissionDeadlineExtendedAt, + status: backendStatus, } = props; - const { status } = useHackathonStatus(startDate, deadline); + const { status } = useHackathonStatus(startDate, deadline, backendStatus); const { isAuthenticated } = useAuthStatus(); const router = useRouter(); const pathname = usePathname(); @@ -98,7 +102,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { return (
- + {imageUrl && (
{/* Prize Pool Section */} {totalPrizePool && ( -
+
- + Prize Pool
-
+
${totalPrizePool}
@@ -162,6 +166,11 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { Deadline {formatDateWithFallback(deadline)} + {submissionDeadlineExtendedAt && ( + + Extended + + )}
)} @@ -202,7 +211,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { onClick={ !isAuthenticated ? handleRedirectToAuth : onJoinClick } - className='w-full bg-[#a7f950] py-4 text-sm font-bold text-black hover:bg-[#8fd93f]' + className='bg-primary w-full py-4 text-sm font-bold text-black hover:bg-[#8fd93f]' > {getRegisterButtonText || 'Join'} @@ -231,7 +240,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { {status === 'ongoing' && isRegistered && !hasSubmitted && (