Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 46 additions & 68 deletions app/(landing)/hackathons/[slug]/HackathonPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, useSearchParams, useParams } from 'next/navigation';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
import { useHackathonAnnouncements } from '@/hooks/hackathon/use-hackathon-queries';
import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon';
import { useLeaveHackathon } from '@/hooks/hackathon/use-leave-hackathon';
import { useSubmission } from '@/hooks/hackathon/use-submission';
Expand All @@ -19,31 +20,34 @@ import { WinnersTab } from '@/components/hackathons/winners/WinnersTab';
import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen';
import { useTimelineEvents } from '@/hooks/hackathon/use-timeline-events';
import { toast } from 'sonner';
import type { Participant } from '@/lib/api/hackathons';
import type { Hackathon, Participant } from '@/lib/api/hackathons';
import { HackathonStickyCard } from '@/components/hackathons/hackathonStickyCard';
import { HackathonParticipants } from '@/components/hackathons/participants/hackathonParticipant';
import { useCommentSystem } from '@/hooks/use-comment-system';
import { CommentEntityType } from '@/types/comment';
import { useTeamPosts } from '@/hooks/hackathon/use-team-posts';
import {
listAnnouncements,
type HackathonAnnouncement,
} from '@/lib/api/hackathons/index';
import { Megaphone } from 'lucide-react';
import { AnnouncementsTab } from '@/components/hackathons/announcements/AnnouncementsTab';
import { reportError, reportMessage } from '@/lib/error-reporting';
import { reportMessage } from '@/lib/error-reporting';

export default function HackathonPageClient() {
interface HackathonPageClientProps {
/** Server-fetched hackathon — seeds React Query cache, eliminates loading state on first render. */
initialHackathon: Hackathon;
}

export default function HackathonPageClient({
initialHackathon,
}: HackathonPageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const params = useParams();

// `currentHackathon` is immediately available — seeded from server via initialData
const {
currentHackathon,
submissions,
winners,
loading,
setCurrentHackathon,
refreshCurrentHackathon,
} = useHackathonData();

Expand Down Expand Up @@ -78,32 +82,9 @@ export default function HackathonPageClient() {
autoFetch: !!hackathonId,
});

// Fetch announcements for public view
const [announcements, setAnnouncements] = useState<HackathonAnnouncement[]>(
[]
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [announcementsLoading, setAnnouncementsLoading] = useState(false);

useEffect(() => {
async function fetchAnnouncements() {
if (!hackathonId) return;
try {
setAnnouncementsLoading(true);
const data = await listAnnouncements(hackathonId);
// Only show published announcements for public view
setAnnouncements(data.filter(a => !a.isDraft));
} catch (error) {
reportError(error, {
context: 'hackathon-fetchAnnouncements',
hackathonId,
});
} finally {
setAnnouncementsLoading(false);
}
}
fetchAnnouncements();
}, [hackathonId]);
// React Query replaces the useEffect+useState announcements pattern
const { data: announcements = [], isLoading: announcementsLoading } =
useHackathonAnnouncements(hackathonId, !!hackathonId);

const hackathonTabs = useMemo(() => {
const hasParticipants =
Expand All @@ -115,7 +96,8 @@ export default function HackathonPageClient() {
const isTeamHackathon =
participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL';

const hasWinners = winners && winners.length > 0;
const hasWinners = (winners && winners.length > 0) || loading;
const hasAnnouncements = announcements.length > 0 || announcementsLoading;

const tabs = [
{ id: 'overview', label: 'Overview' },
Expand All @@ -137,7 +119,7 @@ export default function HackathonPageClient() {
},
]
: []),
...(announcements.length > 0
...(hasAnnouncements
? [
{
id: 'announcements',
Expand Down Expand Up @@ -229,6 +211,8 @@ export default function HackathonPageClient() {
teamPosts.length,
winners,
announcements,
announcementsLoading,
loading,
]);

// Refresh hackathon data
Expand Down Expand Up @@ -318,33 +302,13 @@ export default function HackathonPageClient() {
router.push('?tab=team-formation');
};

// Set current hackathon on mount
const [isInitializing, setIsInitializing] = useState(true);

useEffect(() => {
let isMounted = true;

const initHackathon = async () => {
if (hackathonId) {
await setCurrentHackathon(hackathonId);
}
if (isMounted) {
setIsInitializing(false);
}
};

initHackathon();
// No longer needed — currentHackathon is seeded from server via React Query initialData.

return () => {
isMounted = false;
};
}, [hackathonId, setCurrentHackathon]);

// Handle tab changes from URL
// Now also defaults to 'overview' if the URL tab is not in the filtered hackathonTabs list.
// Handle tab changes from URL.
// Defaults to 'overview' if the URL tab is not in the filtered hackathonTabs list.
// This handles direct URL access to a disabled tab — user is silently redirected to overview.
useEffect(() => {
if (loading || !currentHackathon) return;
if (!currentHackathon) return;

const tabFromUrl = searchParams.get('tab');

Expand All @@ -360,12 +324,26 @@ export default function HackathonPageClient() {
return;
}

// Tab is disabled or unrecognised — fall back to overview
setActiveTab('overview');
const queryParams = new URLSearchParams(searchParams.toString());
queryParams.set('tab', 'overview');
router.replace(`?${queryParams.toString()}`, { scroll: false });
}, [searchParams, hackathonTabs, router, loading, currentHackathon]);
// 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' && loading);

if (!isKnownTabLoading) {
// Tab is disabled or unrecognised — fall back to overview
setActiveTab('overview');
const queryParams = new URLSearchParams(searchParams.toString());
queryParams.set('tab', 'overview');
router.replace(`?${queryParams.toString()}`, { scroll: false });
}
}, [
searchParams,
hackathonTabs,
router,
currentHackathon,
announcementsLoading,
loading,
]);

const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
Expand All @@ -374,8 +352,8 @@ export default function HackathonPageClient() {
router.push(`?${queryParams.toString()}`, { scroll: false });
};

// Loading state
if (loading || isInitializing) {
// Only show a loading screen if data is still being fetched (e.g., window refocus refresh).
if (loading && !currentHackathon) {
return <LoadingScreen />;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ export default function AnnouncementDetailPage() {
<div className='min-h-screen bg-black pb-24'>
{/* Top Header */}
<div className='sticky top-0 z-10 border-b border-zinc-900 bg-black/80 backdrop-blur-md'>
<div className='mx-auto max-w-4xl items-center justify-between px-6 py-4'>
<div className='mx-auto flex max-w-4xl items-center justify-between px-6 py-4'>
<button
onClick={() => window.close()}
onClick={() => router.push(`/hackathons/${slug}?tab=announcements`)}
className='flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-white'
>
<ArrowLeft className='h-4 w-4' />
Close Tab
Back to Hackathon
</button>
<div className='flex items-center gap-2 text-zinc-500'>
<Megaphone className='text-primary h-4 w-4' />
Expand Down Expand Up @@ -170,7 +170,6 @@ export default function AnnouncementDetailPage() {
</div>
</div>
</div>

{/* Content */}
<div className='prose prose-invert prose-primary max-w-none'>
{markdownLoading ? (
Expand All @@ -191,7 +190,7 @@ export default function AnnouncementDetailPage() {
This announcement was published by the hackathon organizers.
</p>
<BoundlessButton
onClick={() => window.close()}
onClick={() => router.push(`/hackathons/${slug}?tab=announcements`)}
variant='outline'
size='sm'
>
Expand Down
18 changes: 18 additions & 0 deletions app/(landing)/hackathons/[slug]/components/Banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Image from 'next/image';
import React from 'react';

const Banner = ({ banner, title }: { banner: string; title?: string }) => {
return (
<div className='relative h-[200px] w-full bg-gray-200 md:h-[360px]'>
<Image
src={banner}
alt={`${title || 'hackathon'} banner`}
fill
className='object-cover'
priority
/>
</div>
);
};

export default Banner;
126 changes: 126 additions & 0 deletions app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'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 { useAuth } from '@/hooks/use-auth';
import SharePopover from '@/components/common/SharePopover';
import { toast } from 'sonner';

const ActionButtons = () => {
const { slug } = useParams<{ slug: string }>();
const router = useRouter();
const { user } = useAuth();
const { currentHackathon: hackathon, refreshCurrentHackathon } =
useHackathonData();
const { data: myTeam } = useMyTeam(slug);

const joinMutation = useJoinHackathon(slug);
const leaveMutation = useLeaveHackathon(slug);

const isParticipant = user
? !!hackathon?.isParticipant ||
(hackathon?.participants || []).some((p: any) => p.userId === user.id)
Comment on lines +28 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "ActionButtons.tsx" | head -20

Repository: boundlessfi/boundless

Length of output: 134


🏁 Script executed:

# Get the file and check the relevant lines
if git ls-files | grep -q "app/(landing)/hackathons/\[slug\]/components/header/ActionButtons.tsx"; then
  cat -n "app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx"
elif git ls-files | grep -q "ActionButtons.tsx"; then
  git ls-files | grep "ActionButtons.tsx"
fi

Repository: boundlessfi/boundless

Length of output: 5311


🏁 Script executed:

# Search for ActionButtons.tsx file
git ls-files | grep -i actionbuttons

Repository: boundlessfi/boundless

Length of output: 190


🏁 Script executed:

# Look for the hackathons directory structure
git ls-files | grep "hackathons.*ActionButtons"

Repository: boundlessfi/boundless

Length of output: 132


🏁 Script executed:

# Search for hackathon type definitions
find . -type f \( -name "*.ts" -o -name "*.tsx" \) | xargs grep -l "type.*Hackathon\|interface.*Hackathon" | head -20

Repository: boundlessfi/boundless

Length of output: 1356


🏁 Script executed:

# Look for participant type definitions
rg "participants.*:" --type ts --type tsx -A 2 | head -50

Repository: boundlessfi/boundless

Length of output: 92


🏁 Script executed:

# Check the hackathonProvider and type definitions
git ls-files | grep -E "provider.*hackathon|hook.*hackathon" | head -20

Repository: boundlessfi/boundless

Length of output: 853


🏁 Script executed:

# Search for the Hackathon model/type
rg "interface Hackathon|type Hackathon" --type ts --type tsx -B 2 -A 10

Repository: boundlessfi/boundless

Length of output: 92


🏁 Script executed:

# Find types directory and Hackathon type
git ls-files | grep -i types | grep -E "\.(ts|d\.ts)$" | head -20

Repository: boundlessfi/boundless

Length of output: 648


🏁 Script executed:

# Search for interface/type Hackathon
rg "interface Hackathon|type Hackathon" -A 15 | head -100

Repository: boundlessfi/boundless

Length of output: 4819


🏁 Script executed:

# Check hackathonProvider file for type info
cat -n lib/providers/hackathonProvider.tsx 2>/dev/null || git ls-files | grep -i provider | grep hackathon

Repository: boundlessfi/boundless

Length of output: 6418


🏁 Script executed:

# Find the hackathons API types file
git ls-files | grep -E "lib/api.*hackathon"

Repository: boundlessfi/boundless

Length of output: 381


🏁 Script executed:

# Check if it's lib/api/hackathons.ts
if [ -f "lib/api/hackathons.ts" ]; then
  cat -n "lib/api/hackathons.ts" | head -150
fi

Repository: boundlessfi/boundless

Length of output: 4678


🏁 Script executed:

# Continue reading the hackathons.ts file to find the main Hackathon type
cat -n "lib/api/hackathons.ts" | tail -n +150 | head -150

Repository: boundlessfi/boundless

Length of output: 4067


🏁 Script executed:

# Also check for participants type definition
rg "participants.*:" lib/api/hackathons.ts -A 3

Repository: boundlessfi/boundless

Length of output: 805


🏁 Script executed:

# Check the participants file
cat -n "lib/api/hackathons/participants.ts"

Repository: boundlessfi/boundless

Length of output: 11796


🏁 Script executed:

# Find the Participant type definition in types/hackathon
cat -n "types/hackathon/index.ts" | head -100

Repository: boundlessfi/boundless

Length of output: 320


🏁 Script executed:

# Also check the core hackathon type file in lib/api/hackathons
cat -n "lib/api/hackathons/core.ts" | head -150

Repository: boundlessfi/boundless

Length of output: 4678


🏁 Script executed:

# Find the Participant type definition
cat -n "types/hackathon/participant.ts"

Repository: boundlessfi/boundless

Length of output: 5805


Replace any type and refresh state after leaving hackathon.

The participants array is already typed in the shared Participant model — any just hides payload drift. Additionally, the handleLeave mutation should refresh the current hackathon like handleJoin does to keep the header state in sync with the backend.

Suggested fix
   const isParticipant = user
     ? !!hackathon?.isParticipant ||
-      (hackathon?.participants || []).some((p: any) => p.userId === user.id)
+      (hackathon?.participants ?? []).some((p: Participant) => p.userId === user.id)
     : false;
   const handleLeave = 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');
     }
   };

Import Participant from @/types/hackathon if not already imported.

As per coding guidelines: "Do not use 'any' type; always search for proper Trustless Work entity types".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx around
lines 28 - 30, Replace the unsafe any by importing and using the Participant
type from "@/types/hackathon" and update the isParticipant check to use that
typed array (participants: Participant[]), and after the handleLeave mutation
ensure you refresh/refetch the current hackathon state the same way handleJoin
does (e.g., call the same refetch/mutate method used in handleJoin) so the
header reflects the backend change; update imports to include Participant and
adjust the participants.some callback to use typed Participant objects and
user.id.

: false;

const handleJoin = 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 = async () => {
try {
await leaveMutation.mutateAsync();
toast.success('You have left the hackathon');
} catch (error: any) {
toast.error(error.message || 'Failed to leave hackathon');
}
Comment on lines +43 to +49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refresh the seeded hackathon state after leaving.

isParticipant is derived from currentHackathon, but the leave path never refreshes it. After a successful leave, this header can keep showing REGISTERED until the page reloads.

Suggested fix
   const handleLeave = 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');
     }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx around
lines 43 - 49, The leave flow in handleLeave calls leaveMutation.mutateAsync but
does not refresh the seeded hackathon state so isParticipant (derived from
currentHackathon) remains stale; after a successful leave, trigger a refresh of
the currentHackathon data (e.g., call your data-layer refetch/invalidate for the
current hackathon query or call router.refresh()) so UI updates immediately;
locate handleLeave/leaveMutation and add a post-success refetch/invalidate for
currentHackathon (or use router.refresh()) and keep the toast.success call after
the refresh.

};

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 (
<div className='flex w-full flex-col gap-3 md:w-auto md:flex-row md:items-center'>
{!isParticipant ? (
<BoundlessButton
className='s d h-12 w-full rounded-xl bg-[#A7F950] px-8 font-bold text-black hover:bg-[#A7F950]/90 disabled:bg-white/5 disabled:text-white/20 md:w-auto'
icon={!isRegistrationClosed && <IconUserPlus size={20} />}
onClick={handleJoin}
loading={joinMutation.isPending}
disabled={isRegistrationClosed}
>
{isRegistrationClosed ? 'REGISTRATION CLOSED' : 'JOIN HACKATHON'}
</BoundlessButton>
) : (
<div className='flex w-full items-center gap-2 md:w-auto'>
<BoundlessButton
variant='outline'
className='h-12 flex-1 rounded-xl border-[#A7F950]/20 bg-[#232B20]/40 px-8 font-black text-[#A7F950] md:flex-none'
disabled
>
REGISTERED
</BoundlessButton>
<BoundlessButton
variant='outline'
size='icon'
className='h-12 w-12 shrink-0 rounded-xl border-red-500/20 bg-red-500/5 text-red-500 hover:border-red-500/30 hover:bg-red-500/10'
onClick={handleLeave}
loading={leaveMutation.isPending}
title='Leave Hackathon'
>
<IconLogout size={20} />
</BoundlessButton>
</div>
)}

<div className='flex w-full gap-3 md:w-auto'>
{!isIndividualOnly && (
<BoundlessButton
variant='outline'
fullWidth
className='h-12 flex-1 rounded-xl border-white/10 bg-white/5 px-6 font-black transition-all hover:border-white/20 hover:bg-white/10'
onClick={() => handleTabChange('team-formation')}
icon={<IconUsers size={20} />}
>
{myTeam ? 'MY TEAM' : 'FIND TEAM'}
</BoundlessButton>
)}

<SharePopover title={hackathon?.name} className='relative shrink-0' />
</div>
</div>
);
};

export default ActionButtons;
20 changes: 20 additions & 0 deletions app/(landing)/hackathons/[slug]/components/header/Logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import Image from 'next/image';

const Logo = ({ logo, title }: { logo: string; title: string }) => {
return (
<div className='flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-[#111111]'>
{logo ? (
<Image
src={logo}
alt={title || 'Logo'}
width={80}
height={80}
className='h-full w-full object-cover'
/>
) : null}
</div>
);
};

export default Logo;
Loading
Loading