Skip to content
Merged
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
38 changes: 32 additions & 6 deletions app/(landing)/hackathons/[slug]/HackathonPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -232,7 +241,7 @@ export default function HackathonPageClient() {
// Registration status
const {
isRegistered,
hasSubmitted,
hasSubmitted: participantHasSubmitted,
setParticipant,
register: registerForHackathon,
} = useRegisterHackathon({
Expand All @@ -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 || '',
Expand Down Expand Up @@ -296,7 +307,7 @@ export default function HackathonPageClient() {
};

const handleSubmitClick = () => {
router.push('?tab=submission');
router.push(`/hackathons/${currentHackathon?.slug}/submit`);
};

const handleViewSubmissionClick = () => {
Expand All @@ -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
Expand Down Expand Up @@ -349,7 +375,7 @@ export default function HackathonPageClient() {
};

// Loading state
if (loading) {
if (loading || isInitializing) {
return <LoadingScreen />;
}

Expand Down
120 changes: 120 additions & 0 deletions app/(landing)/hackathons/[slug]/submit/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingScreen />;
}

return (
<div className='min-h-screen bg-black px-5 py-5 text-white md:px-[50px] lg:px-[100px]'>
<div className='mx-auto max-w-[1200px] pb-10'>
<Button
variant='ghost'
className='mb-6 pl-0 text-gray-400 hover:text-white'
onClick={handleClose}
>
<ArrowLeft className='mr-2 h-4 w-4' />
Back to Hackathon
</Button>

<div className='min-h-[700px] overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50 shadow-2xl'>
<SubmissionFormContent
hackathonSlugOrId={hackathonId}
organizationId={orgId}
submissionId={mySubmission?.id}
initialData={
mySubmission
? {
projectName: mySubmission.projectName,
category: mySubmission.category,
description: mySubmission.description,
logo: mySubmission.logo,
videoUrl: mySubmission.videoUrl,
introduction: mySubmission.introduction,
links: mySubmission.links,
participationType: (mySubmission as any).participationType,
}
: undefined
}
onSuccess={handleSuccess}
onClose={handleClose}
/>
</div>
</div>
</div>
);
}
32 changes: 32 additions & 0 deletions app/(landing)/newsletter/confirm/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Suspense } from 'react';

const msgs: Record<string, string> = {
expired: 'This confirmation link has expired.',
invalid: 'This confirmation link is invalid or already used.',
};

function Content() {
const p = useSearchParams();
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>Confirmation failed</h1>
<p className='text-[#D9D9D9]'>
{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}

export default function Page() {
return (
<Suspense>
<Content />
</Suspense>
);
}
14 changes: 14 additions & 0 deletions app/(landing)/newsletter/confirmed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from 'next/link';
export default function NewsletterConfirmedPage() {
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>You&apos;re subscribed! 🎉</h1>
<p className='text-[#D9D9D9]'>
Your subscription has been confirmed. Welcome aboard!
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}
31 changes: 31 additions & 0 deletions app/(landing)/newsletter/unsubscribe/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Suspense } from 'react';

const msgs: Record<string, string> = {
invalid: 'This unsubscribe link is invalid or has already been used.',
};

function Content() {
const p = useSearchParams();
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>Unsubscribe failed</h1>
<p className='text-[#D9D9D9]'>
{msgs[p.get('reason') ?? ''] ?? 'An unexpected error occurred.'}
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}

export default function Page() {
return (
<Suspense>
<Content />
</Suspense>
);
}
14 changes: 14 additions & 0 deletions app/(landing)/newsletter/unsubscribed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from 'next/link';
export default function NewsletterUnsubscribedPage() {
return (
<main className='flex min-h-screen flex-col items-center justify-center gap-4 text-white'>
<h1 className='text-3xl font-semibold'>You&apos;ve been unsubscribed</h1>
<p className='text-[#D9D9D9]'>
You won&apos;t receive any more emails from us.
</p>
<Link href='/' className='text-[#A7F950] underline'>
Back to home
</Link>
</main>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -298,7 +315,7 @@ export default function AnnouncementPage() {
)}
</div>
<p className='line-clamp-2 text-sm text-zinc-400'>
{item.content.replace(/<[^>]*>/g, '')}
{stripMarkdown(item.content)}
</p>
<div className='flex items-center gap-4 text-xs text-zinc-500'>
<span>
Expand Down
21 changes: 21 additions & 0 deletions app/api/newsletter/confirm/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -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}`
);
}
28 changes: 28 additions & 0 deletions app/api/newsletter/preferences/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading
Loading