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 ( +
+
+ + +
+ +
+
+
+ ); +} 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 && ( )} 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 && ( - - )} + {/* Edit / View Submission Button */} + {status === 'ongoing' && isRegistered && hasSubmitted && ( + + )} {/* 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 = ({
- + {process.env.NODE_ENV === 'development' && ( + + )}
= ({
-
+
-
+
{renderStepContent()}
{currentStep < steps.length - 1 ? ( - - - - - Edit Submission - - e.stopPropagation()}> + + + + + - - Delete Submission - - - + onEditClick?.()} + className='cursor-pointer text-gray-300 focus:bg-gray-800 focus:text-white' + > + + Edit Submission + + onDeleteClick?.()} + className='cursor-pointer text-red-500 focus:bg-red-900/20 focus:text-red-400' + > + + Delete Submission + + + +
)}
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index bf4da83e9..fff6a4974 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -54,6 +54,7 @@ interface SubmissionTabContentProps extends SubmissionTabProps { fetchMySubmission: () => Promise; removeSubmission: (id: string) => Promise; hackathonId: string; + hackathonSlug: string; } const SubmissionTabContent: React.FC = ({ @@ -64,10 +65,10 @@ const SubmissionTabContent: React.FC = ({ fetchMySubmission, removeSubmission, hackathonId, + hackathonSlug, }) => { const { isAuthenticated } = useAuthStatus(); const router = useRouter(); - const { expand } = useExpandableScreen(); const [viewMode, setViewMode] = useState('grid'); @@ -82,7 +83,8 @@ const SubmissionTabContent: React.FC = ({ setSelectedSort, setSelectedCategory, } = useSubmissions(); - const { currentHackathon } = useHackathonData(); + const { currentHackathon, loading: isHackathonDataLoading } = + useHackathonData(); const { status } = useHackathonStatus( currentHackathon?.startDate, currentHackathon?.submissionDeadline @@ -129,6 +131,7 @@ const SubmissionTabContent: React.FC = ({ await removeSubmission(submissionToDelete); setSubmissionToDelete(null); toast.success('Submission deleted successfully'); + window.location.reload(); } catch (error) { reportError(error, { context: 'submission-delete', @@ -263,17 +266,26 @@ const SubmissionTabContent: React.FC = ({
+ {/* 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.

+
+ )} + {!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 => ( + + ))} +
+ +
{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( - `Image` - ) - .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( - `Image` - ) - .run(); - setImageUrl(''); - setIsImageDialogOpen(false); - } - }; - - const handleInsertEmbed = () => { - if (embedUrl) { - editor - .chain() - .focus() - .insertContent( - `` - ) - .run(); - setEmbedUrl(''); - setIsEmbedDialogOpen(false); - } - }; - return (
-
- - - - - - - - - - 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 - -
-
- - setLinkText(e.target.value)} - placeholder='Link text' - className='bg-background border-gray-800 text-white' - /> -
-
- - setLinkUrl(e.target.value)} - placeholder='https://example.com' - className='bg-background border-gray-800 text-white' - /> -
-
- - -
-
-
-
- - - - - - - - Insert Image - -
-
- - setImageUrl(e.target.value)} - placeholder='https://example.com/image.jpg' - className='bg-background border-gray-800 text-white' - /> -
-
- - -
-
-
-
- - - - - - - - Insert Embed - -
-
- - setEmbedUrl(e.target.value)} - placeholder='https://example.com/embed' - className='bg-background border-gray-800 text-white' - /> -
-
- - -
-
-
-
-
- -
- - {(!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);