diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx
index 0c4d1adf3..3f38dce02 100644
--- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx
+++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx
@@ -5,6 +5,8 @@ import { useRouter, useSearchParams, useParams } from 'next/navigation';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon';
import { useLeaveHackathon } from '@/hooks/hackathon/use-leave-hackathon';
+import { useSubmission } from '@/hooks/hackathon/use-submission';
+import { useAuthStatus } from '@/hooks/use-auth';
import { RegisterHackathonModal } from '@/components/hackathons/overview/RegisterHackathonModal';
import { HackathonBanner } from '@/components/hackathons/hackathonBanner';
import { HackathonNavTabs } from '@/components/hackathons/hackathonNavTabs';
@@ -45,6 +47,13 @@ export default function HackathonPageClient() {
refreshCurrentHackathon,
} = useHackathonData();
+ const { isAuthenticated } = useAuthStatus();
+
+ const { submission: mySubmission } = useSubmission({
+ hackathonSlugOrId: currentHackathon?.id || '',
+ autoFetch: !!currentHackathon && isAuthenticated,
+ });
+
const timeline_Events = useTimelineEvents(currentHackathon, {
includeEndDate: false,
dateFormat: { month: 'short', day: 'numeric', year: 'numeric' },
@@ -232,7 +241,7 @@ export default function HackathonPageClient() {
// Registration status
const {
isRegistered,
- hasSubmitted,
+ hasSubmitted: participantHasSubmitted,
setParticipant,
register: registerForHackathon,
} = useRegisterHackathon({
@@ -246,6 +255,8 @@ export default function HackathonPageClient() {
organizationId: undefined,
});
+ const hasSubmitted = !!mySubmission || participantHasSubmitted;
+
// Leave hackathon functionality
const { isLeaving, leave: leaveHackathon } = useLeaveHackathon({
hackathonSlugOrId: currentHackathon?.id || '',
@@ -296,7 +307,7 @@ export default function HackathonPageClient() {
};
const handleSubmitClick = () => {
- router.push('?tab=submission');
+ router.push(`/hackathons/${currentHackathon?.slug}/submit`);
};
const handleViewSubmissionClick = () => {
@@ -308,10 +319,25 @@ export default function HackathonPageClient() {
};
// Set current hackathon on mount
+ const [isInitializing, setIsInitializing] = useState(true);
+
useEffect(() => {
- if (hackathonId) {
- setCurrentHackathon(hackathonId);
- }
+ let isMounted = true;
+
+ const initHackathon = async () => {
+ if (hackathonId) {
+ await setCurrentHackathon(hackathonId);
+ }
+ if (isMounted) {
+ setIsInitializing(false);
+ }
+ };
+
+ initHackathon();
+
+ return () => {
+ isMounted = false;
+ };
}, [hackathonId, setCurrentHackathon]);
// Handle tab changes from URL
@@ -349,7 +375,7 @@ export default function HackathonPageClient() {
};
// Loading state
- if (loading) {
+ if (loading || isInitializing) {
return ;
}
diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx
new file mode 100644
index 000000000..f5ca054e2
--- /dev/null
+++ b/app/(landing)/hackathons/[slug]/submit/page.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import { use, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useHackathonData } from '@/lib/providers/hackathonProvider';
+import { useAuthStatus } from '@/hooks/use-auth';
+import { useSubmission } from '@/hooks/hackathon/use-submission';
+import { SubmissionFormContent } from '@/components/hackathons/submissions/SubmissionForm';
+import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen';
+import { Button } from '@/components/ui/button';
+import { ArrowLeft } from 'lucide-react';
+import { toast } from 'sonner';
+
+export default function SubmitProjectPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const router = useRouter();
+ const { isAuthenticated, isLoading } = useAuthStatus();
+
+ const resolvedParams = use(params);
+ const hackathonSlug = resolvedParams.slug;
+
+ const {
+ currentHackathon,
+ loading: hackathonLoading,
+ setCurrentHackathon,
+ } = useHackathonData();
+
+ useEffect(() => {
+ if (hackathonSlug) {
+ setCurrentHackathon(hackathonSlug);
+ }
+ }, [hackathonSlug, setCurrentHackathon]);
+
+ const hackathonId = currentHackathon?.id || '';
+ const orgId = currentHackathon?.organizationId || undefined;
+
+ const {
+ submission: mySubmission,
+ isFetching: isLoadingMySubmission,
+ fetchMySubmission,
+ } = useSubmission({
+ hackathonSlugOrId: hackathonId || '',
+ autoFetch: isAuthenticated && !!hackathonId,
+ });
+
+ // Authentication check
+ useEffect(() => {
+ if (!isLoading && !isAuthenticated) {
+ toast.error('You must be logged in to submit a project');
+ router.push(
+ `/auth?mode=signin&callbackUrl=/hackathons/${hackathonSlug}/submit`
+ );
+ }
+ }, [isAuthenticated, isLoading, router, hackathonSlug]);
+
+ const handleClose = () => {
+ router.push(`/hackathons/${hackathonSlug}`);
+ };
+
+ const handleSuccess = () => {
+ fetchMySubmission();
+ toast.success(
+ mySubmission
+ ? 'Submission updated successfully!'
+ : 'Project submitted successfully!'
+ );
+ router.push(`/hackathons/${hackathonSlug}?tab=submission`);
+ };
+
+ if (
+ isLoading ||
+ hackathonLoading ||
+ isLoadingMySubmission ||
+ !currentHackathon
+ ) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Back to Hackathon
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(landing)/newsletter/confirm/error/page.tsx b/app/(landing)/newsletter/confirm/error/page.tsx
new file mode 100644
index 000000000..84de7a0ed
--- /dev/null
+++ b/app/(landing)/newsletter/confirm/error/page.tsx
@@ -0,0 +1,32 @@
+'use client';
+import { useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+import { Suspense } from 'react';
+
+const msgs: Record = {
+ expired: 'This confirmation link has expired.',
+ invalid: 'This confirmation link is invalid or already used.',
+};
+
+function Content() {
+ const p = useSearchParams();
+ return (
+
+ Confirmation failed
+
+ {msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
+
+
+ Back to home
+
+
+ );
+}
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/app/(landing)/newsletter/confirmed/page.tsx b/app/(landing)/newsletter/confirmed/page.tsx
new file mode 100644
index 000000000..67718529c
--- /dev/null
+++ b/app/(landing)/newsletter/confirmed/page.tsx
@@ -0,0 +1,14 @@
+import Link from 'next/link';
+export default function NewsletterConfirmedPage() {
+ return (
+
+ You're subscribed! 🎉
+
+ 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
new file mode 100644
index 000000000..b957980a1
--- /dev/null
+++ b/app/(landing)/newsletter/unsubscribe/error/page.tsx
@@ -0,0 +1,31 @@
+'use client';
+import { useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+import { Suspense } from 'react';
+
+const msgs: Record = {
+ invalid: 'This unsubscribe link is invalid or has already been used.',
+};
+
+function Content() {
+ const p = useSearchParams();
+ return (
+
+ Unsubscribe failed
+
+ {msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
+
+
+ Back to home
+
+
+ );
+}
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/app/(landing)/newsletter/unsubscribed/page.tsx b/app/(landing)/newsletter/unsubscribed/page.tsx
new file mode 100644
index 000000000..0d758a3ca
--- /dev/null
+++ b/app/(landing)/newsletter/unsubscribed/page.tsx
@@ -0,0 +1,14 @@
+import Link from 'next/link';
+export default function NewsletterUnsubscribedPage() {
+ return (
+
+ You've been unsubscribed
+
+ You won't receive any more emails from us.
+
+
+ Back to home
+
+
+ );
+}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx
index 60116acbd..20a2d8177 100644
--- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx
+++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx
@@ -29,6 +29,23 @@ import {
import { Switch } from '@/components/ui/switch';
import { reportError } from '@/lib/error-reporting';
+/** Strip Markdown to plain text for list preview (headings, bold, links, etc.). */
+function stripMarkdown(md: string): string {
+ if (!md || typeof md !== 'string') return '';
+ return md
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/#{1,6}\s*/g, '')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/\*([^*]+)\*/g, '$1')
+ .replace(/__([^_]+)__/g, '$1')
+ .replace(/_([^_]+)_/g, '$1')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/<[^>]*>/g, '')
+ .replace(/\n+/g, ' ')
+ .trim();
+}
+
export default function AnnouncementPage() {
const params = useParams();
const organizationId = params.id as string;
@@ -298,7 +315,7 @@ export default function AnnouncementPage() {
)}
- {item.content.replace(/<[^>]*>/g, '')}
+ {stripMarkdown(item.content)}
diff --git a/app/api/newsletter/confirm/[token]/route.ts b/app/api/newsletter/confirm/[token]/route.ts
new file mode 100644
index 000000000..774ba0589
--- /dev/null
+++ b/app/api/newsletter/confirm/[token]/route.ts
@@ -0,0 +1,21 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const backendUrl = process.env.NEXT_PUBLIC_API_URL;
+const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
+
+export async function GET(
+ _req: NextRequest,
+ { params }: { params: Promise<{ token: string }> }
+) {
+ const { token } = await params;
+ const res = await fetch(`${backendUrl}/api/newsletter/confirm/${token}`, {
+ redirect: 'manual',
+ });
+ if (res.status === 302) {
+ return NextResponse.redirect(`${appUrl}/newsletter/confirmed`);
+ }
+ const reason = res.status === 400 ? 'expired' : 'invalid';
+ return NextResponse.redirect(
+ `${appUrl}/newsletter/confirm/error?reason=${reason}`
+ );
+}
diff --git a/app/api/newsletter/preferences/route.ts b/app/api/newsletter/preferences/route.ts
new file mode 100644
index 000000000..36acc11b4
--- /dev/null
+++ b/app/api/newsletter/preferences/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+function normalizeBackendUrl(raw: string | undefined): string | undefined {
+ if (!raw) return undefined;
+ return raw.replace(/\/$/, '').replace(/\/api$/i, '');
+}
+
+const backendUrl = normalizeBackendUrl(process.env.NEXT_PUBLIC_API_URL);
+
+export async function PATCH(req: NextRequest) {
+ const body = await req.json();
+
+ if (!backendUrl) {
+ return NextResponse.json(
+ { message: 'Server configuration error: NEXT_PUBLIC_API_URL is not set' },
+ { status: 500 }
+ );
+ }
+
+ const res = await fetch(`${backendUrl}/api/newsletter/preferences`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return NextResponse.json(await res.json().catch(() => ({})), {
+ status: res.status,
+ });
+}
diff --git a/app/api/newsletter/subscribe/route.ts b/app/api/newsletter/subscribe/route.ts
index 9b7b0fca7..31f0f067f 100644
--- a/app/api/newsletter/subscribe/route.ts
+++ b/app/api/newsletter/subscribe/route.ts
@@ -4,17 +4,14 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
- // Normalize API URL: remove trailing slash and /api if present
- // The env var should be base URL without /api (e.g., https://api.boundlessfi.xyz)
let backendUrl =
process.env.NEXT_PUBLIC_API_URL || 'https://staging-api.boundlessfi.xyz';
backendUrl = backendUrl.replace(/\/$/, '').replace(/\/api$/i, '');
- const response = await fetch(`${backendUrl}/api/waitlist/subscribe`, {
+ const response = await fetch(`${backendUrl}/api/newsletter/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
-
...(request.headers.get('user-agent') && {
'User-Agent': request.headers.get('user-agent')!,
}),
@@ -22,25 +19,11 @@ export async function POST(request: NextRequest) {
body: JSON.stringify(body),
});
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- return NextResponse.json(
- {
- message: errorData.message || 'Failed to subscribe to waitlist',
- status: response.status,
- },
- { status: response.status }
- );
- }
-
- const data = await response.json();
- return NextResponse.json(data, { status: 200 });
+ const data = await response.json().catch(() => ({}));
+ return NextResponse.json(data, { status: response.status });
} catch {
return NextResponse.json(
- {
- message: 'Internal server error',
- status: 500,
- },
+ { message: 'Internal server error', status: 500 },
{ status: 500 }
);
}
diff --git a/app/api/newsletter/unsubscribe/[token]/route.ts b/app/api/newsletter/unsubscribe/[token]/route.ts
new file mode 100644
index 000000000..dfd12ccdb
--- /dev/null
+++ b/app/api/newsletter/unsubscribe/[token]/route.ts
@@ -0,0 +1,20 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const backendUrl = process.env.NEXT_PUBLIC_API_URL;
+const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '';
+
+export async function GET(
+ _req: NextRequest,
+ { params }: { params: Promise<{ token: string }> }
+) {
+ const { token } = await params;
+ const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe/${token}`, {
+ redirect: 'manual',
+ });
+ if (res.status === 302) {
+ return NextResponse.redirect(`${appUrl}/newsletter/unsubscribed`);
+ }
+ return NextResponse.redirect(
+ `${appUrl}/newsletter/unsubscribe/error?reason=invalid`
+ );
+}
diff --git a/app/api/newsletter/unsubscribe/route.ts b/app/api/newsletter/unsubscribe/route.ts
new file mode 100644
index 000000000..64e31d8d9
--- /dev/null
+++ b/app/api/newsletter/unsubscribe/route.ts
@@ -0,0 +1,15 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const backendUrl = process.env.NEXT_PUBLIC_API_URL;
+
+export async function POST(req: NextRequest) {
+ const body = await req.json();
+ const res = await fetch(`${backendUrl}/api/newsletter/unsubscribe`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return NextResponse.json(await res.json().catch(() => ({})), {
+ status: res.status,
+ });
+}
diff --git a/app/globals.css b/app/globals.css
index f26eec3e0..9d5ab0c8b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -412,6 +412,21 @@ input[type='number'] {
transition: all 0.5s ease-out;
}
+/* Navbar active state – brand color via CSS variables (Tailwind-safe) */
+:root {
+ --nav-active-bg: rgba(167, 249, 80, 0.1);
+ --nav-active-color: #a7f950;
+ --nav-active-border: rgba(167, 249, 80, 0.2);
+ --nav-active-shadow: 0 1px 2px rgba(167, 249, 80, 0.05);
+}
+
+.navbar-link-active {
+ background-color: var(--nav-active-bg);
+ color: var(--nav-active-color);
+ border: 1px solid var(--nav-active-border);
+ box-shadow: var(--nav-active-shadow);
+}
+
/* Navbar character animations */
.nav-char {
display: inline-block;
diff --git a/components/hackathons/announcements/AnnouncementsTab.tsx b/components/hackathons/announcements/AnnouncementsTab.tsx
index 621b2304e..e10c9b69d 100644
--- a/components/hackathons/announcements/AnnouncementsTab.tsx
+++ b/components/hackathons/announcements/AnnouncementsTab.tsx
@@ -5,6 +5,26 @@ import { Megaphone, Pin, ArrowUpDown, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { HackathonAnnouncement } from '@/lib/api/hackathons/index';
import Link from 'next/link';
+import { useMarkdown } from '@/hooks/use-markdown';
+
+/** Renders announcement body as Markdown (supports both Markdown and legacy HTML). */
+function AnnouncementPreview({ content }: { content: string }) {
+ const raw = content?.trim() || '';
+ const isLikelyHtml = raw.startsWith('<');
+ const markdown = isLikelyHtml ? raw.replace(/<[^>]*>/g, ' ') : raw;
+ const { styledContent, loading } = useMarkdown(markdown, {
+ loadingDelay: 0,
+ });
+
+ if (!raw) return No content ;
+ if (loading) return … ;
+
+ return (
+
+ {styledContent}
+
+ );
+}
interface AnnouncementsTabProps {
announcements: HackathonAnnouncement[];
@@ -90,9 +110,7 @@ export function AnnouncementsTab({
-
- {announcement.content.replace(/<[^>]*>/g, '')}
-
+
diff --git a/components/hackathons/hackathonBanner.tsx b/components/hackathons/hackathonBanner.tsx
index dcb8ccd55..f08d27b0a 100644
--- a/components/hackathons/hackathonBanner.tsx
+++ b/components/hackathons/hackathonBanner.tsx
@@ -315,14 +315,14 @@ export function HackathonBanner({
{status === 'ongoing' &&
isRegistered &&
hasSubmitted &&
- onViewSubmissionClick && (
+ onSubmitClick && (
- View Submission
+ Edit Submission
)}
diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx
index 470562408..9a4a9c2a0 100644
--- a/components/hackathons/hackathonStickyCard.tsx
+++ b/components/hackathons/hackathonStickyCard.tsx
@@ -238,20 +238,17 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
)}
- {/* View Submission Button */}
- {status === 'ongoing' &&
- isRegistered &&
- hasSubmitted &&
- onViewSubmissionClick && (
-
-
- View Submission
-
- )}
+ {/* Edit / View Submission Button */}
+ {status === 'ongoing' && isRegistered && hasSubmitted && (
+
+
+ Edit Submission
+
+ )}
{/* Find Team Button */}
{status === 'ongoing' &&
diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx
index abb2e0e4f..1604189b6 100644
--- a/components/hackathons/submissions/SubmissionForm.tsx
+++ b/components/hackathons/submissions/SubmissionForm.tsx
@@ -125,6 +125,7 @@ interface SubmissionFormContentProps {
initialData?: Partial;
submissionId?: string;
onSuccess?: () => void;
+ onClose?: () => void;
}
const INITIAL_STEPS: Step[] = [
@@ -198,8 +199,19 @@ const SubmissionFormContent: React.FC = ({
initialData,
submissionId,
onSuccess,
+ onClose,
}) => {
- const { collapse, isExpanded: open } = useExpandableScreen();
+ // Use context carefully since it might not be available when used standalone
+ let collapse = () => {};
+ let open = true;
+ try {
+ const expandableCtx = useExpandableScreen();
+ collapse = expandableCtx.collapse;
+ open = expandableCtx.isExpanded;
+ } catch (e) {
+ // Standalone mode, not in ExpandableScreen
+ }
+
const { currentHackathon } = useHackathonData();
const { user } = useAuthStatus();
@@ -773,7 +785,11 @@ const SubmissionFormContent: React.FC = ({
} else {
await create(submissionData);
}
- collapse();
+ if (onClose) {
+ onClose();
+ } else {
+ collapse();
+ }
onSuccess?.();
} catch {
// Error handled in hook
@@ -1081,15 +1097,17 @@ const SubmissionFormContent: React.FC = ({
-
- Fill with Mock Data
-
+ {process.env.NODE_ENV === 'development' && (
+
+ Fill with Mock Data
+
+ )}
= ({
+ {/* Loading State */}
+ {(isLoadingMySubmission || isHackathonDataLoading) && (
+
+
+ Loading submissions...
+
+ )}
+
{/* Submissions Grid with Create Button if no submission */}
{!isLoadingMySubmission &&
!mySubmission &&
isAuthenticated &&
- isRegistered && (
+ isRegistered &&
+ status !== 'upcoming' && (
You haven't submitted a project yet.
router.push(`/hackathons/${hackathonSlug}/submit`)}
disabled={isDeadlinePassed}
className='bg-[#a7f950] text-black hover:bg-[#8fd93f] disabled:cursor-not-allowed disabled:opacity-50'
>
@@ -286,7 +298,9 @@ const SubmissionTabContent: React.FC = ({
)}
{/* Submissions Grid / List */}
- {submissions.length > 0 || mySubmission ? (
+ {!isLoadingMySubmission &&
+ !isHackathonDataLoading &&
+ (submissions.length > 0 || mySubmission) ? (
= ({
isMySubmission={true}
isDeadlinePassed={isDeadlinePassed}
onViewClick={() => handleViewSubmission(mySubmission.id)}
- onEditClick={expand}
+ onEditClick={() =>
+ router.push(`/hackathons/${hackathonSlug}/submit`)
+ }
onDeleteClick={() => handleDeleteClick(mySubmission.id)}
onUpvoteClick={() => {}}
onCommentClick={() => {}}
@@ -493,33 +509,12 @@ const SubmissionTab: React.FC
= ({
fetchMySubmission,
remove: removeSubmission,
} = useSubmission({
- hackathonSlugOrId: hackathonSlug || '',
+ hackathonSlugOrId: hackathonId || '',
autoFetch: isAuthenticated && !!hackathonId,
});
return (
- {
- fetchMySubmission();
- }}
- >
+
= ({
await removeSubmission(id);
}}
hackathonId={hackathonId}
+ hackathonSlug={hackathonSlug}
/>
-
+
);
};
diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx
index aa86df94f..01fe37b4a 100644
--- a/components/landing-page/navbar.tsx
+++ b/components/landing-page/navbar.tsx
@@ -9,7 +9,6 @@ import {
User as UserIcon,
LogOut,
Settings,
- Sparkles,
Wand2,
} from 'lucide-react';
import Image from 'next/image';
@@ -83,9 +82,8 @@ export function Navbar() {
{/* Desktop Navigation */}
- {/* Desktop Actions - Updated with better alignment */}
- {/* */}
-
+ {/* Desktop Actions: single flex group for correct layout */}
+
{isLoading ? (
) : isAuthenticated ? (
@@ -95,13 +93,29 @@ export function Navbar() {
)}
- {/* Mobile Menu */}
-
- {/*
*/}
+ {/* Mobile: right-aligned group — notification, wallet (if auth), hamburger; placeholders when loading to avoid layout jump */}
+
+ {isAuthenticated && isLoading && (
+
+
+
+
+ )}
+ {isAuthenticated && !isLoading && (
+ <>
+
+
+ >
+ )}
+
+
@@ -142,7 +156,7 @@ function DesktopMenu({
}) {
return (
@@ -156,24 +170,9 @@ function DesktopMenu({
className={cn(
'rounded-lg px-3 py-2 text-sm font-medium transition-all duration-100',
isActive
- ? `bg-[${BRAND_COLOR}]/10 text-[${BRAND_COLOR}] border border-[${BRAND_COLOR}]/20 shadow-sm shadow-[${BRAND_COLOR}]/5`
- : 'text-white/60 hover:bg-white/5 hover:text-white/90',
- item.href === '/bounties'
- ? 'max-[1200px]:hidden'
- : item.href === '/blog'
- ? 'max-[1200px]:hidden'
- : ''
+ ? 'navbar-link-active'
+ : 'text-white/60 hover:bg-white/5 hover:text-white/90'
)}
- style={
- isActive
- ? {
- backgroundColor: `${BRAND_COLOR}1A`,
- color: BRAND_COLOR,
- borderColor: `${BRAND_COLOR}33`,
- boxShadow: `0 1px 2px ${BRAND_COLOR}0D`,
- }
- : undefined
- }
>
{item.label}
@@ -212,14 +211,17 @@ function AuthenticatedActions() {
<>
-
+
{
setIsHovered(true);
e.currentTarget.style.backgroundColor = `${BRAND_COLOR}1A`;
@@ -233,13 +235,8 @@ function AuthenticatedActions() {
e.currentTarget.style.color = '';
}}
>
- {/* Animated sparkle that travels across */}
-
-
-
-
Create
@@ -270,8 +267,7 @@ function AuthenticatedActions() {
@@ -282,14 +278,16 @@ function AuthenticatedActions() {
-
+
-
-
- Sign In
-
+
+
+ Sign In
+
+
+ Get Started
+
+
+
-
+
{isAuthenticated && user && (
@@ -442,7 +448,6 @@ const MobileMenu = ({
)}
- {/* Navigation Links */}
{MENU_ITEMS.map(item => {
const isActive = pathname === item.href;
@@ -453,20 +458,11 @@ const MobileMenu = ({
onClick={() => setIsOpen(false)}
aria-current={isActive ? 'page' : undefined}
className={cn(
- 'rounded-lg py-3 text-sm font-medium transition-all duration-200',
+ 'flex min-h-[44px] items-center rounded-lg border px-3 py-3 text-sm font-medium transition-all duration-200',
isActive
- ? 'border'
- : 'text-white/70 hover:border hover:border-white/10 hover:bg-white/5 hover:text-white/90'
+ ? 'navbar-link-active'
+ : 'text-white/70 hover:border-white/10 hover:bg-white/5 hover:text-white/90'
)}
- style={
- isActive
- ? {
- backgroundColor: `${BRAND_COLOR}1A`,
- color: BRAND_COLOR,
- borderColor: `${BRAND_COLOR}33`,
- }
- : undefined
- }
>
{item.label}
@@ -474,61 +470,54 @@ const MobileMenu = ({
})}
- {isAuthenticated && (
-
- {isLoading ? (
-
- ) : (
- <>
-
- {user?.username && (
- setIsOpen(false)}
- >
-
-
- Profile
-
-
- )}
- setIsOpen(false)}>
-
-
- Settings
-
-
- {
- logout();
- setIsOpen(false);
- }}
- >
-
- Sign Out
-
- >
- )}
-
- )}
+ {isAuthenticated && isLoading &&
}
- {/* Sign Up / Sign In Buttons - Fixed at bottom for unauthenticated users */}
+ {/* Bottom: account actions – Profile, Settings, Sign Out (authenticated) or Get Started + Sign In (unauthenticated) */}
+ {isAuthenticated && !isLoading && (
+
+ {user?.username && (
+ setIsOpen(false)}
+ className='mb-3 flex min-h-[44px] w-full items-center justify-center rounded-[10px] border border-white/30 px-4 py-3 text-sm font-medium text-white transition-colors hover:border-white/40 hover:bg-white/10'
+ >
+ Profile
+
+ )}
+ setIsOpen(false)}
+ className='mb-3 flex min-h-[44px] w-full items-center justify-center rounded-[10px] border border-white/30 px-4 py-3 text-sm font-medium text-white transition-colors hover:border-white/40 hover:bg-white/10'
+ >
+ Settings
+
+ {
+ logout();
+ setIsOpen(false);
+ }}
+ >
+ Sign Out
+
+
+ )}
+
{!isAuthenticated && (
-
+
setIsOpen(false)}
- className='inline-flex h-9 w-full items-center justify-center gap-2 rounded-[10px] bg-[#a7f950] px-4 py-2 text-sm font-medium whitespace-nowrap text-black shadow-sm shadow-[#a7f950]/20 transition-all hover:bg-[#a7f950]/90'
+ className='inline-flex min-h-[44px] w-full items-center justify-center rounded-[10px] bg-[#a7f950] px-4 py-3 text-sm font-medium text-black shadow-sm shadow-[#a7f950]/20 transition-colors hover:bg-[#a7f950]/90'
>
Get Started
setIsOpen(false)}
- className='inline-flex h-9 w-full items-center justify-center gap-2 rounded-[10px] border border-white/30 px-4 py-2 text-sm font-medium whitespace-nowrap text-white transition-all hover:border-white/40 hover:bg-white/10'
+ className='inline-flex min-h-[44px] w-full items-center justify-center rounded-[10px] border border-white/30 px-4 py-3 text-sm font-medium text-white transition-colors hover:border-white/40 hover:bg-white/10'
>
Sign In
diff --git a/components/organization/hackathons/settings/GeneralSettingsTab.tsx b/components/organization/hackathons/settings/GeneralSettingsTab.tsx
index 78fd89486..e0b2ce74d 100644
--- a/components/organization/hackathons/settings/GeneralSettingsTab.tsx
+++ b/components/organization/hackathons/settings/GeneralSettingsTab.tsx
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
-import { AlertCircle } from 'lucide-react';
+import { AlertCircle, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
@@ -34,15 +34,14 @@ import {
} from '@/lib/country-utils';
import { toast } from 'sonner';
-const DynamicMinimalTiptap = dynamic(
- () =>
- import('@/components/ui/shadcn-io/minimal-tiptap').then(mod => ({
- default: mod.MinimalTiptap,
- })),
+const MDEditor = dynamic(
+ () => import('@uiw/react-md-editor').then(mod => mod.default),
{
ssr: false,
loading: () => (
-
+
+
+
),
}
);
@@ -264,10 +263,32 @@ export default function GeneralSettingsTab({
Details
*
-
-
+ field.onChange(value || '')}
+ height={300}
+ data-color-mode='dark'
+ preview='edit'
+ hideToolbar={false}
+ visibleDragbar={true}
+ textareaProps={{
+ placeholder:
+ "Tell your hackathon's story...\n\nUse markdown for formatting: headings, lists, links, and more.",
+ style: {
+ fontSize: 14,
+ lineHeight: 1.6,
+ color: '#ffffff',
+ backgroundColor: '#18181b', // matching InfoTab style
+ fontFamily: 'inherit',
+ border: 'none',
+ },
+ }}
+ style={{
+ backgroundColor: '#18181b', // matching InfoTab style
+ color: '#ffffff',
+ border: 'none',
+ }}
/>
diff --git a/components/overview/Newsletter.tsx b/components/overview/Newsletter.tsx
index eac5a25d6..f2360d1b6 100644
--- a/components/overview/Newsletter.tsx
+++ b/components/overview/Newsletter.tsx
@@ -25,7 +25,11 @@ import { BoundlessButton } from '../buttons';
import { Input } from '../ui/input';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
-import { newsletterSubscribe } from '@/lib/api/waitlist';
+import {
+ newsletterSubscribe,
+ type NewsletterApiError,
+ type NewsletterTag,
+} from '@/lib/api/waitlist';
import { Button } from '../ui/button';
const formSchema = z.object({
@@ -42,6 +46,7 @@ const Newsletter = ({
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState
(null);
+ const [selectedTags, setSelectedTags] = useState([]);
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -82,15 +87,23 @@ const Newsletter = ({
const onSubmit = async (values: z.infer) => {
setError(null);
setIsSubmitting(true);
-
try {
await newsletterSubscribe({
email: values.email,
name: values.name,
+ source: 'website',
+ tags: selectedTags,
});
- } catch {
- setError('Failed to submit form. Please try again.');
- setIsSubmitting(false);
+ onOpenChange(false);
+ window.location.href = '/newsletter/confirmed';
+ } catch (err) {
+ const e = err as NewsletterApiError;
+ if (e.code === 'ALREADY_SUBSCRIBED')
+ setError('This email is already subscribed.');
+ else if (e.code === 'RATE_LIMITED')
+ setError('Too many attempts. Please try again in a minute.');
+ else if (e.code === 'INVALID_TAGS') setError('Invalid topic selection.');
+ else setError('Failed to submit form. Please try again.');
} finally {
setIsSubmitting(false);
}
@@ -186,6 +199,37 @@ const Newsletter = ({
)}
/>
+
+
+ {(
+ [
+ 'bounties',
+ 'hackathons',
+ 'grants',
+ 'updates',
+ ] as NewsletterTag[]
+ ).map(tag => (
+
+
+ setSelectedTags(p =>
+ p.includes(tag)
+ ? p.filter(t => t !== tag)
+ : [...p, tag]
+ )
+ }
+ />
+ {tag.charAt(0).toUpperCase() + tag.slice(1)}
+
+ ))}
+
+
+
{steps.map((step, index) => {
const styles = getStepStyles(step.state);
const isLastStep = index === steps.length - 1;
return (
-
-
+
+ {/* Horizontal Line for Mobile (connects to next step circle) */}
+ {!isLastStep && (
+
+ )}
+
+
{step.state === 'completed' ? (
-
-
-
+
) : (
{index + 1}
)}
+ {/* Vertical Line for Desktop */}
{!isLastStep && (
)}
-
-
+
+
{step.title}
-
+
{step.description}
diff --git a/components/ui/shadcn-io/announcement-editor/index.tsx b/components/ui/shadcn-io/announcement-editor/index.tsx
index d29c90eb1..60d8efb9d 100644
--- a/components/ui/shadcn-io/announcement-editor/index.tsx
+++ b/components/ui/shadcn-io/announcement-editor/index.tsx
@@ -1,40 +1,21 @@
'use client';
import * as React from 'react';
-import { EditorContent, useEditor } from '@tiptap/react';
-import StarterKit from '@tiptap/starter-kit';
-import { Button } from '@/components/ui/button';
-import { Separator } from '@/components/ui/separator';
-import { Toggle } from '@/components/ui/toggle';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import {
- Bold,
- Italic,
- Strikethrough,
- Code,
- Quote,
- Link as LinkIcon,
- Image as ImageIcon,
- Undo,
- Redo,
- Code2,
-} from 'lucide-react';
+import dynamic from 'next/dynamic';
+import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
+
+const MDEditor = dynamic(
+ () => import('@uiw/react-md-editor').then(mod => mod.default),
+ {
+ ssr: false,
+ loading: () => (
+
+
+
+ ),
+ }
+);
interface AnnouncementEditorProps {
content?: string;
@@ -51,129 +32,6 @@ function AnnouncementEditor({
editable = true,
className,
}: AnnouncementEditorProps) {
- const [linkUrl, setLinkUrl] = React.useState('');
- const [linkText, setLinkText] = React.useState('');
- const [imageUrl, setImageUrl] = React.useState('');
- const [embedUrl, setEmbedUrl] = React.useState('');
- const [isLinkDialogOpen, setIsLinkDialogOpen] = React.useState(false);
- const [isImageDialogOpen, setIsImageDialogOpen] = React.useState(false);
- const [isEmbedDialogOpen, setIsEmbedDialogOpen] = React.useState(false);
-
- const editor = useEditor({
- extensions: [
- StarterKit.configure({
- bulletList: {
- keepMarks: true,
- keepAttributes: false,
- },
- orderedList: {
- keepMarks: true,
- keepAttributes: false,
- },
- }),
- ],
- content,
- editable,
- immediatelyRender: false,
- onUpdate: ({ editor }) => {
- onChange?.(editor.getHTML());
- },
- editorProps: {
- attributes: {
- class: cn(
- 'prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl mx-auto focus:outline-none',
- 'min-h-[400px] border-0 p-6 text-white'
- ),
- },
- },
- });
-
- React.useEffect(() => {
- if (editor && content !== editor.getHTML()) {
- editor.commands.setContent(content);
- }
- }, [content, editor]);
-
- React.useEffect(() => {
- if (editor) {
- editor.setOptions({
- editorProps: {
- ...editor.options.editorProps,
- handleDOMEvents: {
- ...editor.options.editorProps?.handleDOMEvents,
- drop: (view, event) => {
- const files = event.dataTransfer?.files;
- if (files && files.length > 0) {
- const file = files[0];
- if (file.type.startsWith('image/')) {
- const reader = new FileReader();
- reader.onload = e => {
- const src = e.target?.result as string;
- editor
- .chain()
- .focus()
- .insertContent(
- ` `
- )
- .run();
- };
- reader.readAsDataURL(file);
- return true;
- }
- }
- return false;
- },
- },
- },
- });
- }
- }, [editor]);
-
- if (!editor) {
- return null;
- }
-
- const handleInsertLink = () => {
- if (linkUrl && linkText) {
- editor
- .chain()
- .focus()
- .insertContent(`${linkText} `)
- .run();
- setLinkUrl('');
- setLinkText('');
- setIsLinkDialogOpen(false);
- }
- };
-
- const handleInsertImage = () => {
- if (imageUrl) {
- editor
- .chain()
- .focus()
- .insertContent(
- ` `
- )
- .run();
- setImageUrl('');
- setIsImageDialogOpen(false);
- }
- };
-
- const handleInsertEmbed = () => {
- if (embedUrl) {
- editor
- .chain()
- .focus()
- .insertContent(
- ``
- )
- .run();
- setEmbedUrl('');
- setIsEmbedDialogOpen(false);
- }
- };
-
return (
-
-
editor.chain().focus().undo().run()}
- disabled={!editor.can().chain().focus().undo().run()}
- className='h-8 w-8 p-0 text-gray-400 hover:bg-gray-800 hover:text-white'
- >
-
-
-
editor.chain().focus().redo().run()}
- disabled={!editor.can().chain().focus().redo().run()}
- className='h-8 w-8 p-0 text-gray-400 hover:bg-gray-800 hover:text-white'
- >
-
-
-
-
-
-
{
- if (value === 'normal') {
- editor.chain().focus().setParagraph().run();
- } else if (value === 'h1') {
- editor.chain().focus().toggleHeading({ level: 1 }).run();
- } else if (value === 'h2') {
- editor.chain().focus().toggleHeading({ level: 2 }).run();
- } else if (value === 'h3') {
- editor.chain().focus().toggleHeading({ level: 3 }).run();
- }
- }}
- >
-
-
-
-
- Normal
- Heading 1
- Heading 2
- Heading 3
-
-
-
-
-
-
editor.chain().focus().toggleBold().run()}
- disabled={!editor.can().chain().focus().toggleBold().run()}
- className='h-8 w-8 p-0 data-[state=on]:bg-gray-800 data-[state=on]:text-white'
- >
-
-
-
-
editor.chain().focus().toggleItalic().run()}
- disabled={!editor.can().chain().focus().toggleItalic().run()}
- className='h-8 w-8 p-0 data-[state=on]:bg-gray-800 data-[state=on]:text-white'
- >
-
-
-
-
editor.chain().focus().toggleStrike().run()}
- disabled={!editor.can().chain().focus().toggleStrike().run()}
- className='h-8 w-8 p-0 data-[state=on]:bg-gray-800 data-[state=on]:text-white'
- >
-
-
-
-
editor.chain().focus().toggleCode().run()}
- disabled={!editor.can().chain().focus().toggleCode().run()}
- className='h-8 w-8 p-0 data-[state=on]:bg-gray-800 data-[state=on]:text-white'
- >
-
-
-
-
- editor.chain().focus().toggleBlockquote().run()
- }
- className='h-8 w-8 p-0 data-[state=on]:bg-gray-800 data-[state=on]:text-white'
- >
-
-
-
-
-
-
-
-
-
-
-
- Insert Link
-
-
-
-
- Link Text
-
- setLinkText(e.target.value)}
- placeholder='Link text'
- className='bg-background border-gray-800 text-white'
- />
-
-
-
- URL
-
- setLinkUrl(e.target.value)}
- placeholder='https://example.com'
- className='bg-background border-gray-800 text-white'
- />
-
-
- setIsLinkDialogOpen(false)}
- className='border-gray-800 text-white hover:bg-gray-800'
- >
- Cancel
-
-
- Insert
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Insert Image
-
-
-
-
- Image URL
-
- setImageUrl(e.target.value)}
- placeholder='https://example.com/image.jpg'
- className='bg-background border-gray-800 text-white'
- />
-
-
- setIsImageDialogOpen(false)}
- className='border-gray-800 text-white hover:bg-gray-800'
- >
- Cancel
-
-
- Insert
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Insert Embed
-
-
-
-
- Embed URL
-
- setEmbedUrl(e.target.value)}
- placeholder='https://example.com/embed'
- className='bg-background border-gray-800 text-white'
- />
-
-
- setIsEmbedDialogOpen(false)}
- className='border-gray-800 text-white hover:bg-gray-800'
- >
- Cancel
-
-
- Insert
-
-
-
-
-
-
-
-
-
- {(!editor.getHTML() || editor.getHTML() === '
') && (
-
- {placeholder}
-
- )}
-
+
onChange?.(value ?? '')}
+ height={400}
+ data-color-mode='dark'
+ preview='edit'
+ hideToolbar={!editable}
+ visibleDragbar={editable}
+ textareaProps={{
+ placeholder,
+ readOnly: !editable,
+ style: {
+ fontSize: 14,
+ lineHeight: 1.6,
+ color: '#ffffff',
+ backgroundColor: '#18181b',
+ fontFamily: 'inherit',
+ border: 'none',
+ },
+ }}
+ style={{
+ backgroundColor: '#18181b',
+ color: '#ffffff',
+ border: 'none',
+ }}
+ />
);
}
diff --git a/hooks/hackathon/use-participants.ts b/hooks/hackathon/use-participants.ts
index dc287b90a..f6fe782bc 100644
--- a/hooks/hackathon/use-participants.ts
+++ b/hooks/hackathon/use-participants.ts
@@ -1,6 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
import { getTeamPosts, type TeamRecruitmentPost } from '@/lib/api/hackathons';
+import { getHackathonParticipants } from '@/lib/api/hackathon';
import { reportError } from '@/lib/error-reporting';
import { useParams } from 'next/navigation';
@@ -8,24 +9,36 @@ export function useParticipants() {
const { currentHackathon } = useHackathonData();
const params = useParams();
const [teams, setTeams] = useState([]);
+ const [apiParticipants, setApiParticipants] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
const hackathonId = currentHackathon?.id || (params?.slug as string);
// Fetch teams to get accurate team info and roles
useEffect(() => {
if (hackathonId) {
- getTeamPosts(hackathonId, { limit: 50 })
- .then(response => {
- if (response.success && response.data) {
- // Check if response.data is the array or if it's nested in .teams
+ setIsLoading(true);
+ Promise.all([
+ getTeamPosts(hackathonId, { limit: 50 }),
+ getHackathonParticipants(hackathonId, { limit: 100 }),
+ ])
+ .then(([teamsResponse, participantsResponse]) => {
+ if (teamsResponse.success && teamsResponse.data) {
const teamsArray =
- (response.data as any).teams ||
- (Array.isArray(response.data) ? response.data : []);
+ (teamsResponse.data as any).teams ||
+ (Array.isArray(teamsResponse.data) ? teamsResponse.data : []);
setTeams(teamsArray);
}
+
+ if (participantsResponse.success && participantsResponse.data) {
+ setApiParticipants(participantsResponse.data.participants || []);
+ }
})
.catch(err => {
- reportError(err, { context: 'participants-fetchTeams', hackathonId });
+ reportError(err, { context: 'participants-fetchData', hackathonId });
+ })
+ .finally(() => {
+ setIsLoading(false);
});
}
}, [hackathonId]);
@@ -66,66 +79,143 @@ export function useParticipants() {
}, [teams]);
// Transform API participants to match expected Participant type
- const participants: Array<{
- id: string;
- userId: string;
- name: string;
- username: string;
- avatar: string;
- hasSubmitted: boolean;
- joinedDate: string;
- role: string;
- categories: string[];
- projects: number;
- followers: number;
- teamId?: string;
- teamName?: string;
- isIndividual: boolean;
- }> = (currentHackathon?.participants || []).map(apiParticipant => {
- const apiUser = (apiParticipant.user || {}) as any;
- const profile = (apiUser.profile || {}) as any;
- const userId = apiParticipant.userId || apiUser.id;
-
- // Enrich with team data from fetched teams
- const teamInfo = userId ? userTeamMap.get(userId) : null;
-
- // Robust name detection
- const name =
- apiUser.name ||
- profile.name ||
- `${profile.firstName || apiUser.firstName || ''} ${profile.lastName || apiUser.lastName || ''}`.trim() ||
- apiUser.displayUsername ||
- 'Anonymous';
-
- // Robust username detection
- const username =
- apiUser.username ||
- profile.username ||
- apiUser.displayUsername ||
- apiUser.handle ||
- 'user';
-
- const avatar = profile.image || profile.avatar || apiUser.image || '';
-
- return {
- id: apiParticipant.id,
- userId: userId,
- name,
- username,
- avatar,
- hasSubmitted: !!apiParticipant.submission,
- joinedDate: apiParticipant.registeredAt,
- // Use role from Team if found, then from API, otherwise default
- role: teamInfo?.role || (apiParticipant as any).role || 'Participant',
- categories: [],
- projects: 0,
- followers: 0,
- teamId: teamInfo?.teamId || apiParticipant.teamId,
- teamName: teamInfo?.teamName || apiParticipant.teamName,
- isIndividual:
- apiParticipant.participationType === 'individual' && !teamInfo,
- };
- });
+ const participants = useMemo(() => {
+ // We want to merge data from both sources.
+ // currentHackathon?.participants has the Google avatars (user.image).
+ // apiParticipants has the stats (followers, projects).
+ const baseParticipants = currentHackathon?.participants || [];
+ const sourceParticipants =
+ apiParticipants.length > 0 ? apiParticipants : baseParticipants;
+
+ // Create a lookup map from base participants for fast merging
+ const baseLookup = new Map();
+ baseParticipants.forEach(p => {
+ const uId = p.userId || (p.user || {}).id || p.id;
+ if (uId) baseLookup.set(uId, p);
+ });
+
+ return sourceParticipants.map(apiParticipant => {
+ // Find matching base participant to merge data
+ const pId =
+ apiParticipant.userId ||
+ (apiParticipant.user || {}).id ||
+ apiParticipant.id;
+ const basePat = pId ? baseLookup.get(pId) : null;
+
+ // Merge user objects to ensure we don't lose avatar data (like Google profile images)
+ const apiUser = {
+ ...(basePat?.user || {}),
+ ...(apiParticipant.user || {}),
+ ...((apiParticipant.user || apiParticipant || {}) as any),
+ };
+ const profile = {
+ ...(basePat?.user?.profile || {}),
+ ...(apiUser.profile || {}),
+ } as any;
+
+ // Robust userId detection
+ const userId =
+ apiParticipant.userId ||
+ apiUser.id ||
+ apiUser.userId ||
+ (typeof apiParticipant.id === 'string' ? apiParticipant.id : undefined);
+
+ // Enrich with team data from fetched teams
+ const teamInfo = userId ? userTeamMap.get(userId) : null;
+
+ // Robust name detection
+ const name =
+ apiParticipant.name ||
+ basePat?.name ||
+ apiUser.name ||
+ profile.name ||
+ `${profile.firstName || apiUser.firstName || apiParticipant.firstName || ''} ${profile.lastName || apiUser.lastName || apiParticipant.lastName || ''}`.trim() ||
+ apiUser.displayUsername ||
+ apiParticipant.displayUsername ||
+ apiUser.displayName ||
+ apiParticipant.displayName ||
+ 'Anonymous';
+
+ // Robust username detection
+ const username =
+ apiParticipant.username ||
+ basePat?.username ||
+ apiUser.username ||
+ profile.username ||
+ apiUser.displayUsername ||
+ apiParticipant.displayUsername ||
+ apiUser.handle ||
+ apiParticipant.handle ||
+ 'user';
+
+ const avatar =
+ profile.image ||
+ profile.avatar ||
+ profile.avatarUrl ||
+ profile.imageUrl ||
+ profile.picture ||
+ profile.photoURL ||
+ apiUser.image ||
+ apiUser.avatar ||
+ apiUser.avatarUrl ||
+ apiUser.imageUrl ||
+ apiUser.picture ||
+ apiUser.photo ||
+ apiUser.photoURL ||
+ apiParticipant.avatar ||
+ apiParticipant.image ||
+ apiParticipant.avatarUrl ||
+ apiParticipant.imageUrl ||
+ apiParticipant.picture ||
+ apiParticipant.photo ||
+ '/placeholder.svg';
+
+ // Get joined date - prefer registeredAt
+ const joinedDate =
+ apiParticipant.registeredAt ||
+ basePat?.registeredAt ||
+ apiParticipant.createdAt ||
+ new Date().toISOString();
+
+ // Get stats if available in enriched profile or participant object
+ const userStats = (apiParticipant as any).userStats || {};
+ const projectsCount =
+ apiParticipant.projects ??
+ userStats.projects ??
+ profile.projectsCount ??
+ 0;
+ const followersCount =
+ apiParticipant.followers ??
+ userStats.followers ??
+ profile.followersCount ??
+ 0;
+
+ return {
+ id: apiParticipant.id || basePat?.id,
+ userId: userId,
+ name: name === ' ' ? 'Anonymous' : name, // Fix empty space from trim
+ username,
+ avatar,
+ hasSubmitted: !!(apiParticipant.submission || basePat?.submission),
+ joinedDate,
+ // Use role from Team if found, then from API, otherwise default
+ role:
+ teamInfo?.role ||
+ (apiParticipant as any).role ||
+ (basePat as any)?.role ||
+ 'Participant',
+ categories: apiParticipant.categories || basePat?.categories || [],
+ projects: projectsCount,
+ followers: followersCount,
+ teamId: teamInfo?.teamId || apiParticipant.teamId || basePat?.teamId,
+ teamName:
+ teamInfo?.teamName || apiParticipant.teamName || basePat?.teamName,
+ isIndividual:
+ (apiParticipant.participationType || basePat?.participationType) ===
+ 'individual' && !teamInfo,
+ };
+ });
+ }, [apiParticipants, currentHackathon?.participants, userTeamMap]);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('newest');
const [submissionFilter, setSubmissionFilter] = useState('all');
@@ -142,7 +232,7 @@ export function useParticipants() {
p.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.role && p.role.toLowerCase().includes(searchTerm.toLowerCase())) ||
(p.categories &&
- p.categories.some(cat =>
+ p.categories.some((cat: string) =>
cat.toLowerCase().includes(searchTerm.toLowerCase())
))
);
@@ -162,7 +252,9 @@ export function useParticipants() {
p =>
(p.role && p.role.toLowerCase().includes(skillFilter)) ||
(p.categories &&
- p.categories.some(cat => cat.toLowerCase().includes(skillFilter)))
+ p.categories.some((cat: string) =>
+ cat.toLowerCase().includes(skillFilter)
+ ))
);
}
diff --git a/lib/api/waitlist.ts b/lib/api/waitlist.ts
index c16b9761f..8a34d0571 100644
--- a/lib/api/waitlist.ts
+++ b/lib/api/waitlist.ts
@@ -7,17 +7,61 @@ type AddToWaitlistRequest = {
tags?: string[];
};
+export type NewsletterTag = 'bounties' | 'hackathons' | 'grants' | 'updates';
+
+export const NEWSLETTER_TAGS: NewsletterTag[] = [
+ 'bounties',
+ 'hackathons',
+ 'grants',
+ 'updates',
+];
+
type NewsletterSubscribeRequest = {
email: string;
- name: string;
+ name?: string;
+ source?: string;
+ tags?: NewsletterTag[];
+};
+
+type NewsletterUnsubscribeRequest = {
+ email: string;
+};
+
+type NewsletterPreferencesRequest = {
+ email: string;
+ tags: NewsletterTag[];
+};
+
+export type NewsletterApiError = {
+ status: number;
+ code:
+ | 'INVALID_TAGS'
+ | 'ALREADY_SUBSCRIBED'
+ | 'RATE_LIMITED'
+ | 'NOT_FOUND'
+ | 'UNKNOWN';
+ message: string;
};
+const codeMap: Record = {
+ 400: 'INVALID_TAGS',
+ 404: 'NOT_FOUND',
+ 409: 'ALREADY_SUBSCRIBED',
+ 429: 'RATE_LIMITED',
+};
+
+function throwApiError(status: number, body: { message?: string }): never {
+ throw {
+ status,
+ code: codeMap[status] ?? 'UNKNOWN',
+ message: body.message ?? 'An unexpected error occurred.',
+ } as NewsletterApiError;
+}
+
export const addToWaitlist = async (data: AddToWaitlistRequest) => {
const res = await fetch('/api/waitlist/subscribe', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
@@ -32,16 +76,48 @@ export const addToWaitlist = async (data: AddToWaitlistRequest) => {
export const newsletterSubscribe = async (data: NewsletterSubscribeRequest) => {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
- if (!res.ok) {
- const errorData = await res.json().catch(() => ({}));
- throw new Error(errorData.message || 'Failed to subscribe to newsletter');
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throwApiError(res.status, body);
+ return body as { message: string; id: string };
+};
+
+export const newsletterUnsubscribe = async (
+ data: NewsletterUnsubscribeRequest
+) => {
+ const res = await fetch('/api/newsletter/unsubscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throwApiError(res.status, body);
+ return body as { message: string };
+};
+
+export const newsletterUpdatePreferences = async (
+ data: NewsletterPreferencesRequest
+) => {
+ const invalid = data.tags.filter(t => !NEWSLETTER_TAGS.includes(t));
+ if (invalid.length > 0) {
+ throw {
+ status: 400,
+ code: 'INVALID_TAGS',
+ message: `Invalid tags: ${invalid.join(', ')}. Allowed: ${NEWSLETTER_TAGS.join(', ')}.`,
+ } as NewsletterApiError;
}
- return res.json();
+ const res = await fetch('/api/newsletter/preferences', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throwApiError(res.status, body);
+ return body as { message: string };
};
diff --git a/lib/providers/hackathonProvider.tsx b/lib/providers/hackathonProvider.tsx
index 11261f04d..e6b26fbbe 100644
--- a/lib/providers/hackathonProvider.tsx
+++ b/lib/providers/hackathonProvider.tsx
@@ -322,8 +322,6 @@ export function HackathonDataProvider({
// --------------------------------
const setCurrentHackathon = useCallback(
async (slug: string) => {
- if (currentHackathonSlug === slug && fetchingRef.current) return;
-
setCurrentHackathonSlug(slug);
const data = await fetchHackathonBySlug(slug);