diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index 0c4d1adf..3f38dce0 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]/hackathon-detail-design.md b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md new file mode 100644 index 00000000..cdb81ff1 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/hackathon-detail-design.md @@ -0,0 +1,30 @@ +Hackathon Detail Page Redesign +Issue: #414 + +Figma Design +https://www.figma.com/design/EMNGAQl1SGObXcsoa24krt/Boundless_Project-Details?node-id=0-1&t=A1ywRcn60Xyw0X6h-1 + +This design proposes a cleaner and more professional UI/UX for the hackathon detail page. + +Included in the Figma file: +- Desktop layout +- Mobile layout +- Banner / hero placement proposal +- Redesigned hero section +- Sticky sidebar card +- Tab navigation +- All tab layouts (overview, participants, resources, announcements, submissions, discussions, find team, winners) +- Loading state +- Hackathon not found state + +Banner Placement Proposal +The hackathon banner is placed as a full-width hero image at the top of the page, allowing it to visually represent the hackathon and improve page identity. + +The sidebar becomes a compact summary card with key information and actions. + +Design Goals +- Simpler UI and improved visual hierarchy +- Clear primary actions (Join, Submit, View Submission) +- Consistent spacing and typography +- Better mobile usability +- Professional and product-quality look diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx new file mode 100644 index 00000000..f0721551 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { use, useEffect, useState } 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`); + }; + + const [hasInitialLoaded, setHasInitialLoaded] = useState(false); + + useEffect(() => { + if (!isLoading && !hackathonLoading && !isLoadingMySubmission) { + setHasInitialLoaded(true); + } + }, [isLoading, hackathonLoading, isLoadingMySubmission]); + + if (!hasInitialLoaded) { + return ; + } + + if (!currentHackathon) { + return ( +
+

Hackathon Not Found

+ +
+ ); + } + return ( +
+
+ + +
+ +
+
+
+ ); +} diff --git a/app/(landing)/newsletter/confirm/error/page.tsx b/app/(landing)/newsletter/confirm/error/page.tsx new file mode 100644 index 00000000..84de7a0e --- /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 00000000..67718529 --- /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 00000000..b957980a --- /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 00000000..0d758a3c --- /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 60116acb..20a2d817 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 00000000..774ba058 --- /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 00000000..36acc11b --- /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 9b7b0fca..31f0f067 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 00000000..dfd12ccd --- /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 00000000..64e31d8d --- /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 f26eec3e..9d5ab0c8 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/app/me/crowdfunding/[slug]/milestones/[milestoneIndex]/page.tsx b/app/me/crowdfunding/[slug]/milestones/[milestoneIndex]/page.tsx index 2d346395..7aa3c0bf 100644 --- a/app/me/crowdfunding/[slug]/milestones/[milestoneIndex]/page.tsx +++ b/app/me/crowdfunding/[slug]/milestones/[milestoneIndex]/page.tsx @@ -162,31 +162,31 @@ export default function MilestoneDetailPage({ params }: PageProps) { // Step 3: Perform blockchain transaction with Trustless Work SDK toast('Please confirm the transaction in your wallet'); - const { unsignedTransaction } = await changeMilestoneStatus( - { - contractId: campaign.escrowAddress, - milestoneIndex: (milestone.orderIndex ?? milestoneIndex).toString(), - newStatus: data.status === 'completed' ? 'completed' : 'in_progress', - newEvidence: data.submissionNotes, // Use submission notes as evidence description - serviceProvider: walletAddress || '', - }, - 'multi-release' - ); - if (!unsignedTransaction) { - throw new Error( - 'Unsigned transaction is missing from useChangeMilestoneStatusresponse.' - ); - } - - const signedXdr = await signTransaction({ - unsignedTransaction, - address: walletAddress || '', - }); - - const trxsent = await sendTransaction(signedXdr); - if (trxsent.status === 'SUCCESS') { - toast.success('Transaction confirmed on blockchain'); - } + // const { unsignedTransaction } = await changeMilestoneStatus( + // { + // contractId: campaign.escrowAddress, + // milestoneIndex: (milestone.orderIndex ?? milestoneIndex).toString(), + // newStatus: data.status === 'completed' ? 'completed' : 'in_progress', + // newEvidence: data.submissionNotes, // Use submission notes as evidence description + // serviceProvider: walletAddress || '', + // }, + // 'multi-release' + // ); + // if (!unsignedTransaction) { + // throw new Error( + // 'Unsigned transaction is missing from useChangeMilestoneStatusresponse.' + // ); + // } + + // const signedXdr = await signTransaction({ + // unsignedTransaction, + // address: walletAddress || '', + // }); + + // const trxsent = await sendTransaction(signedXdr); + // if (trxsent.status === 'SUCCESS') { + // toast.success('Transaction confirmed on blockchain'); + // } toast('Updating milestone...'); diff --git a/app/me/projects/page.tsx b/app/me/projects/page.tsx index 4c61e8f1..fd6c819b 100644 --- a/app/me/projects/page.tsx +++ b/app/me/projects/page.tsx @@ -102,14 +102,23 @@ export default function MyProjectsPage() { ) : (
- {sortedProjects.map(project => ( - { + // Find if this project is a hackathon submission + const submission = + meData.user.hackathonSubmissionsAsParticipant?.find( + s => s.projectId === project.id + ); + + // If it's a submission, use the submission ID for the slug to ensure correct redirection from ProjectCard + const displayId = submission?.id || project.id; + + return ( + - ))} + isSubmission: !!submission, + submissionStatus: submission?.status, + }} + /> + ); + })}
)}
diff --git a/components/hackathons/announcements/AnnouncementsTab.tsx b/components/hackathons/announcements/AnnouncementsTab.tsx index 621b2304..e10c9b69 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 dcb8ccd5..f08d27b0 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 47056240..9a4a9c2a 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 abb2e0e4..cabc7abe 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -34,6 +34,7 @@ import { ExpandableScreenContent, ExpandableScreenTrigger, useExpandableScreen, + useOptionalExpandableScreen, } from '@/components/ui/expandable-screen'; import Stepper from '@/components/stepper/Stepper'; import { uploadService } from '@/lib/api/upload'; @@ -125,6 +126,7 @@ interface SubmissionFormContentProps { initialData?: Partial; submissionId?: string; onSuccess?: () => void; + onClose?: () => void; } const INITIAL_STEPS: Step[] = [ @@ -198,8 +200,12 @@ const SubmissionFormContent: React.FC = ({ initialData, submissionId, onSuccess, + onClose, }) => { - const { collapse, isExpanded: open } = useExpandableScreen(); + const expandableCtx = useOptionalExpandableScreen(); + const collapse = expandableCtx?.collapse ?? (() => {}); + const open = expandableCtx?.isExpanded ?? true; + const { currentHackathon } = useHackathonData(); const { user } = useAuthStatus(); @@ -404,7 +410,7 @@ const SubmissionFormContent: React.FC = ({ description: 'An intelligent task management application that uses machine learning to prioritize tasks...', logo: '', - videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + videoUrl: 'https://youtu.be/rOSZhhblE_8?si=Hf_YvPTMmyWTUOKQ', introduction: 'This project leverages advanced AI algorithms...', links: [ { type: 'github', url: 'https://github.com/example/ai-task-manager' }, @@ -773,8 +779,13 @@ const SubmissionFormContent: React.FC = ({ } else { await create(submissionData); } - collapse(); - onSuccess?.(); + if (onSuccess) { + onSuccess(); + } else if (onClose) { + onClose(); + } else { + collapse(); + } } catch { // Error handled in hook } @@ -1081,15 +1092,17 @@ const SubmissionFormContent: React.FC = ({
- + {process.env.NODE_ENV === 'development' && ( + + )}
= ({
-
+
-
+
{renderStepContent()}
{currentStep < steps.length - 1 ? ( - - - - - Edit Submission - - e.stopPropagation()}> + + + + + - - Delete Submission - - - + + + Edit Submission + + + + Delete Submission + + + +
)}
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index bf4da83e..29193c83 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,9 @@ const SubmissionTabContent: React.FC = ({ setSelectedSort, setSelectedCategory, } = useSubmissions(); - const { currentHackathon } = useHackathonData(); + + const { currentHackathon, loading: isHackathonDataLoading } = + useHackathonData(); const { status } = useHackathonStatus( currentHackathon?.startDate, currentHackathon?.submissionDeadline @@ -129,6 +132,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 +267,27 @@ const SubmissionTabContent: React.FC = ({
+ {/* Loading State */} + {(isLoadingMySubmission || isHackathonDataLoading) && ( +
+ + Loading submissions... +
+ )} + {/* Submissions Grid with Create Button if no submission */} {!isLoadingMySubmission && + !isHackathonDataLoading && !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/notifications/NotificationDropdown.tsx b/components/notifications/NotificationDropdown.tsx index 63f36bc3..c92a780e 100644 --- a/components/notifications/NotificationDropdown.tsx +++ b/components/notifications/NotificationDropdown.tsx @@ -211,7 +211,7 @@ export const NotificationDropdown = ({ )} diff --git a/components/organization/hackathons/settings/GeneralSettingsTab.tsx b/components/organization/hackathons/settings/GeneralSettingsTab.tsx index 78fd8948..1c1e84e6 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: () => ( -
+
+ +
), } ); @@ -56,6 +55,8 @@ interface GeneralSettingsTabProps { isPublished?: boolean; } +import TurndownService from 'turndown'; + export default function GeneralSettingsTab({ organizationId, hackathonId, @@ -79,6 +80,24 @@ export default function GeneralSettingsTab({ address: string; } | null>(null); + // Normalize HTML to Markdown for existing descriptions + const normalizedDescription = React.useMemo(() => { + let desc = initialData?.description || ''; + if (desc && /<[a-z][\\s\\S]*>/i.test(desc)) { + try { + const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', + bulletListMarker: '-', + }); + desc = turndownService.turndown(desc); + } catch (err) { + console.error('Failed to convert HTML to Markdown', err); + } + } + return desc; + }, [initialData?.description]); + const form = useForm({ resolver: zodResolver(infoSchema), defaultValues: { @@ -86,7 +105,7 @@ export default function GeneralSettingsTab({ tagline: initialData?.tagline || '', slug: initialData?.slug || '', banner: initialData?.banner || '', - description: initialData?.description || '', + description: normalizedDescription, categories: Array.isArray(initialData?.categories) ? initialData.categories : [], @@ -264,10 +283,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 eac5a25d..f2360d1b 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 => ( + + ))} +
+
- {projects.map(project => ( - - { + // Find if this project is a hackathon submission + // We need to check if the user object has submissions + const submission = + user.user.hackathonSubmissionsAsParticipant?.find( + s => s.projectId === project.id + ); + + // If it's a submission, use the submission ID for the URL and add ?type=submission + const displayId = submission?.id || project.id; + const href = submission + ? `/projects/${displayId}?type=submission` + : `/projects/${displayId}`; + + return ( + + - - ))} + }, + isSubmission: !!submission, + submissionStatus: submission?.status, + }} + /> + + ); + })} {!hasMore && projects.length > 0 && (
diff --git a/components/profile/ProjectsTabPublic.tsx b/components/profile/ProjectsTabPublic.tsx index 478c1faf..ccbf7194 100644 --- a/components/profile/ProjectsTabPublic.tsx +++ b/components/profile/ProjectsTabPublic.tsx @@ -33,14 +33,26 @@ export default function ProjectsTabPublic({ user }: ProjectsTabProps) {
- {user.projects.map(project => ( - - { + // Find if this project is a hackathon submission + const submission = user.hackathonSubmissionsAsParticipant?.find( + s => s.projectId === project.id + ); + + // If it's a submission, use the submission ID for the URL and add ?type=submission + const displayId = submission?.id || project.id; + const href = submission + ? `/projects/${displayId}?type=submission` + : `/projects/${displayId}`; + + return ( + + - - ))} + // Add a custom property to indicate it's a submission for ProjectCard + isSubmission: !!submission, + submissionStatus: submission?.status, + }} + /> + + ); + })}
); diff --git a/components/stepper/Stepper.tsx b/components/stepper/Stepper.tsx index 68f7ae56..1c5bcee9 100644 --- a/components/stepper/Stepper.tsx +++ b/components/stepper/Stepper.tsx @@ -43,39 +43,57 @@ function Stepper({ steps }: StepperProps) { }; return ( -
+
{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/expandable-screen.tsx b/components/ui/expandable-screen.tsx index 7eaf54e9..4894a27b 100644 --- a/components/ui/expandable-screen.tsx +++ b/components/ui/expandable-screen.tsx @@ -34,6 +34,10 @@ function useExpandableScreen() { return context; } +export function useOptionalExpandableScreen() { + return useContext(ExpandableScreenContext); +} + // Root Component interface ExpandableScreenProps { children: ReactNode; diff --git a/components/ui/shadcn-io/announcement-editor/index.tsx b/components/ui/shadcn-io/announcement-editor/index.tsx index d29c90eb..60d8efb9 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/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx b/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx new file mode 100644 index 00000000..166a9c1c --- /dev/null +++ b/content/blog/what-do-you-know-about-boundless-on-x-challenge.mdx @@ -0,0 +1,157 @@ +--- +title: 'What Do You Know About Boundless? The X Challenge' +excerpt: 'Whether you’re a builder, creator, or meme-lover, the Boundless on X Challenge is your chance to explain the platform in your own words. Share your thoughts and win a share of the $100 USDC prize pool!' +coverImage: 'https://res.cloudinary.com/dmsphf4d3/image/upload/v1773048379/boundless-on-x.webp' +publishedAt: '2026-03-09' +author: + name: 'Boundless Team' + image: '' +categories: ['Community', 'Challenge', 'Events'] +tags: + ['boundless', 'challenge', 'x', 'twitter', 'community', 'stellar', 'rewards'] +readingTime: 3 +isFeatured: true +seoTitle: 'What Do You Know About Boundless? Join the X Challenge' +seoDescription: 'Join the Boundless on X Challenge! Explain the platform in your own words through a tweet, thread, or meme, and win your share of $100 USDC.' +--- + +# What Do You Know About Boundless? + +We want to hear your take. + +Whether you’re a builder, creator, or meme-lover, the **Boundless on X Challenge** is your chance to explain the platform in your own words. Share a tweet, thread, or visual post, and the best entries will help shape how the world understands Boundless — and win some **USDC**! + +--- + +## So, What’s the Boundless on X Challenge All About? + +Our mission at **Boundless** is simple: to empower anyone, anywhere, to transform bold ideas into impactful projects with **transparency, community, and accountability** at the core. + +Ecosystems grow through conversation. The more people understand a platform, the easier it is for new builders to join and for great ideas to gain traction. The **Boundless on X Challenge** is your chance to contribute by telling our story in your own way. + +If you’re reading this, you’re probably researching to participate in the challenge. + +Awesome — we’re thrilled to have you. Stick around until the end of this article and you’ll get some **insider tips** to help your post stand out. + +--- + +# Finish the Challenge in Three Simple Steps + +Completing this challenge only takes **three steps** and a couple of minutes. + +## 1. Register and Follow Boundless + +To participate, go to **[boundlesfi.xyz](https://www.boundlessfi.xyz/hackathons/boundless-on-x-test?tab=participants)** and register for **Boundless on X**. + +This is a community challenge, so join the community on **X (formerly Twitter)** `@boundless_fi` and on **Discord** to meet the community. + +--- + +## 2. Choose Your Category and Create Your Post + +Share an impactful and/or entertaining **tweet, thread, or meme** about Boundless. + +**Must-haves:** + +- Your post must include **#BoundlessBuild** +- Tag both **@boundless_fi** and **@BuildOnStellar** + +--- + +## 3. Submit Your Entry + +Copy the link to your post and submit it through the **challenge page** with a short description. + +**Done.** + +--- + +# Categories You Can Join + +You can join the challenge by participating in **any of these categories**. Pick the format that best suits your creative style. + +## Threads + +Share a short, engaging **thread of 3–5 tweets** that explains Boundless or tells a story about the platform. + +## Single Tweets + +Write **one clear, impactful, concise, or clever tweet** that captures what Boundless is all about. + +## Memes or Visuals + +Get creative with an **image, graphic, or short video** that explains or promotes Boundless in a fun way. + +Winners will be selected **in each category**, along with **one overall standout entry** that really captures the spirit of the challenge. + +--- + +# What Are the Best Participants Creating? + +Top entries are the ones that: + +- Explain **Boundless** like you’re telling a friend who’s never heard of it. +- Highlight **why Boundless’ mission matters**. +- Share **what excites you most about the ecosystem**. +- Bring the idea to life with **humor, visuals, or storytelling**. + +No single approach is _“correct,”_ but these tips can help your post stand out: + +- **Keep it personal.** Your voice matters more than AI-generated material — use it sparingly or not at all. +- **Clarity over virality.** Likes and retweets are nice, but a clear, compelling explanation carries the most weight. +- **Be authentic.** Show your enthusiasm and perspective; genuine posts resonate more with the community. + +--- + +# Prizes and Key Dates + +The challenge features a **$100 USDC prize pool**, shared by the top entries selected from across all categories. + +Winning entries may also be **highlighted across the Boundless community**. + +The challenge runs for a short period, so make sure to submit your entry before the deadline: + +- **Challenge kicks off:** Saturday, March 7, 2026 +- **Last chance to submit:** Wednesday, March 11, 2026 at **12:00 PM UTC** + +--- + +# Got Questions? We’ve Got Answers + +## Who can participate? + +Anyone can join. If you have an **X account** and want to share something about Boundless, you’re welcome to take part. + +## Do I need to be a developer? + +Not at all. The challenge is open to **builders, creators, writers, designers, meme makers**, and anyone interested in the ecosystem. + +## Can I participate in more than one category? + +Yes. You can submit entries in **multiple categories**. + +For example, you might create a thread explaining Boundless and also submit a meme or visual. Each entry should be **submitted separately** with its own link and description. + +## What’s off-limits? + +**NSFW, offensive, or harmful content** is a no-go. Stick to posts that **inform, entertain, or inspire** in a way everyone can enjoy. + +## Want to ask more? + +If you have questions that aren’t answered here, don’t be shy. + +**[Tag us on X](https://x.com/boundless_fi)** or **[Start a conversation in Discord](https://discord.gg/tgpFpSHG)**. We love hearing from the community. + +--- + +# Ready to Jump In? + +Your voice matters. + +Create your post, submit it, and share your excitement about **Boundless** — for the love of the game, for a bit of glory, and for the chance to win some fun rewards too! + +Finally, as promised, here are some **insider resources** to help your post stand out and guide you through the challenge: + +- **[Quick start guide to join Boundless](https://docs.boundlessfi.xyz/getting-started/quick-start)** +- **[How Boundless works](https://docs.boundlessfi.xyz/concepts/how-boundless-works)** +- **[General FAQs](https://docs.boundlessfi.xyz/faq/general)** diff --git a/docs/bug-test-report.md b/docs/bug-test-report.md new file mode 100644 index 00000000..e09a659e --- /dev/null +++ b/docs/bug-test-report.md @@ -0,0 +1,123 @@ +# Bug Test Report: Organization & Onboarding Flow + +**Project:** BoundlessFi +**Date:** March 7, 2026 +**Environment:** Frontend (Local) / Backend (Staging API) +**Status:** Issue #448 Verification Complete + +--- + +## 1. Executive Summary + +Overall, core organization features (creation, search, and navigation) are stable. However, critical blockers exist within the **Invitation Lifecycle** and **Hackathon Publishing** flows. Additionally, UX refinements are needed for mobile navigation and input validation. + +--- + +## 2. Issue #448 Verification Status [COMPLETED] + +All items specified in Issue #448 have been verified for production readiness: + +| Feature | Status | Notes | +| :----------------------- | :----- | :--------------------------------------------------- | +| **Public Profile Route** | Pass | `/org/[slug]` functions correctly end-to-end. | +| **OG Metadata** | Pass | Title, description, and images validated. | +| **Settings Tabs** | Pass | Profile, Links, Members, and Transfer verified. | +| **Sidebar Actions** | Pass | Host Hackathon and Grants (disabled state) verified. | +| **Search/Sort** | Pass | Search and sort keys behave as expected. | +| **Deep-linking** | Pass | Direct URL loading for organizations is functional. | +| **Header Search** | Pass | `⌘K` navigation is bug-free. | +| **Profile Validation** | Pass | Slug/Profile validation logic is functional. | +| **Mobile Responsive** | Pass | Hamburger menu is functional on settings pages. | + +--- + +## 3. High-Priority Bugs + +### BUG-001: Invite Link Routing Mismatch (404) + +- **Issue:** Generated invite links point to `/signup` instead of the active `/auth` route. +- **Example Link:** `https://staging.boundlessfi.xyz/signup?invitationId=...` +- **Impact:** Users cannot join organizations via email. +- **Recommended Fix:** Update `getInvitationUrl` in the backend/auth-config to target `/auth`. + +### BUG-002: Invite Acceptance Logic Failure + +- **Issue:** Manually correcting the URL to `/auth` allows sign-up, but the user is not associated with the organization. +- **Actual Behavior:** User is redirected to home; status remains "Pending"; member list is not updated. +- **Expected Behavior:** User should automatically join the organization and redirect to the org dashboard. + +### BUG-003: Hackathon Publish Endpoint (404) + +- **Action:** Click **Publish** in the Preview tab for a draft hackathon. +- **Observed Error:** `PUT .../api/organizations/{orgId}/hackathons/draft/{hackathonId}/publish` returns **404 Not Found**. +- **Likely Root Cause:** Wallet balance is insufficient for publish-related on-chain or fee-dependent operations. +- **UX Gap:** The UI does not tell users that the publish failure is caused by **insufficient wallet funds**. +- **Impact:** Users cannot move hackathons from "Draft" to "Live" and do not know how to resolve it. +- **Suggestion:** Show a clear inline/toast error such as: `Insufficient wallet funds to publish. Please fund your wallet and try again.` + +**Suggested User Guidance (Fund Wallet Steps)** + +1. Open wallet settings from the profile/wallet menu. +2. Copy your connected wallet address. +3. Add funds to that wallet on the required network (via exchange transfer, bridge, or faucet for test/staging). +4. Wait for transaction confirmation and refresh the app. +5. Return to the hackathon draft and click **Publish** again. + +### BUG-004: Delete/Archive Organization Non-Functional + +- **Issue:** Actions to delete or archive an organization fail. + +**What I tested** + +- Opened `/organizations`. +- Selected an organization. +- Clicked the **Archive** button and completed the confirmation prompt. +- Clicked the **Delete** button and completed the confirmation prompt. + +**Observed Result** + +- Both actions failed. +- The organization was not archived and not deleted. + +**Expected Result** + +- Archive should move the organization to archived state. +- Delete should remove the organization. + +**Scope** + +- Test type: Manual UI test. +- Environment: Frontend local + staging backend. +- Role: Owner. +- Organizations tested: 1 organization in this pass. + +## 4. UI/UX Improvements + +### UX-001: Mobile Navigation Tabs (Host Hackathon) + +- **Observation:** Sub-navigation tabs (e.g., Participation, Edit) are difficult to access/truncated on mobile screens. +- **Suggestion:** Implement **horizontal scrollable tabs** for sub-nav menus to improve mobile accessibility. + +### UX-002: Slug Availability Feedback + +- **Issue:** The UI displays "Slug available" even for taken slugs, only failing upon final form submission. +- **Suggestion:** Real-time validation should return "Slug already taken" before the user attempts to submit. + +### UX-003: Password Strength Feedback + +- **Issue:** Form submission is blocked for weak passwords without explaining why. +- **Suggestion:** Add a helper text: _"Password must be at least 8 characters and include a number."_ + +--- + +## 5. Technical Edge Cases + +### Route Guarding: Invalid Organization IDs + +- **Issue:** Manually entering an invalid ID (e.g., `/organizations/random123/settings`) still renders the settings UI shell. +- **Recommendation:** Implement a check to return a 404 page if the Organization ID does not exist in the database. + +--- + +**Reported by:** QA Team +**Branch:** `Test/Test-the-organization-flow---manual-QA-checklist` diff --git a/features/projects/components/ProjectCard.tsx b/features/projects/components/ProjectCard.tsx index 8dd194c8..155c4ec3 100644 --- a/features/projects/components/ProjectCard.tsx +++ b/features/projects/components/ProjectCard.tsx @@ -3,10 +3,36 @@ import { formatNumber, cn } from '@/lib/utils'; import { useRouter } from 'nextjs-toploader/app'; import Image from 'next/image'; import { CountdownTimer } from '@/components/ui/timer'; -import { Crowdfunding } from '@/features/projects/types'; + +export type ProjectCardData = { + id: string; + slug: string; + project: { + id?: string; + title: string; + vision?: string | null; + banner?: string | null; + logo?: string | null; + creator?: { name: string; image?: string | null }; + category?: string | null; + status: string; + _count?: { votes?: number }; + [key: string]: any; + }; + fundingGoal?: number; + fundingRaised?: number; + fundingCurrency?: string; + fundingEndDate?: string | null; + milestones?: any[]; + voteGoal?: number; + voteProgress?: number; + isSubmission?: boolean; + submissionStatus?: string | null; + [key: string]: any; +}; type ProjectCardProps = { - data: Crowdfunding; + data: ProjectCardData; newTab?: boolean; isFullWidth?: boolean; className?: string; @@ -23,13 +49,15 @@ function ProjectCard({ const { slug, project, - fundingGoal, - fundingRaised, - fundingCurrency, - fundingEndDate, - milestones, - voteGoal, - voteProgress, + fundingGoal = 0, + fundingRaised = 0, + fundingCurrency = 'USDC', + fundingEndDate = null, + milestones = [], + voteGoal = 0, + voteProgress = 0, + isSubmission, + submissionStatus, } = data; const { @@ -47,11 +75,22 @@ function ProjectCard({ banner || '/images/placeholders/project-banner-placeholder.png'; const handleClick = () => { - router.push(`/projects/${slug}`); + const url = isSubmission + ? `/projects/${slug}?type=submission` + : `/projects/${slug}`; + router.push(url); }; // Determine display status const getDisplayStatus = () => { + if (isSubmission) { + if (submissionStatus === 'SHORTLISTED' || submissionStatus === 'ACCEPTED') + return 'Shortlisted'; + if (submissionStatus === 'SUBMITTED') return 'Submitted'; + if (submissionStatus === 'DISQUALIFIED') return 'Disqualified'; + return 'Submission'; + } + if (projectStatus === 'IDEA') return 'Validation'; if (projectStatus === 'ACTIVE') return 'Funding'; if (projectStatus === 'LIVE') return 'Funded'; diff --git a/features/projects/types/index.ts b/features/projects/types/index.ts index bce8bcc4..7d0418da 100644 --- a/features/projects/types/index.ts +++ b/features/projects/types/index.ts @@ -82,6 +82,13 @@ export interface PublicUserProfile { logo: string; createdAt: string; }>; + hackathonSubmissionsAsParticipant?: Array<{ + id: string; + projectId: string; + status: string; + hackathonId: string; + projectName: string; + }>; badges: any[]; stats: { projectsCreated: number; diff --git a/hooks/hackathon/use-participants.ts b/hooks/hackathon/use-participants.ts index dc287b90..45e43122 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,25 +9,56 @@ 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); + const hackathonId = currentHackathon?.id; // 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); + + const fetchAllData = async () => { + try { + // Fetch teams + const teamsResponse = await getTeamPosts(hackathonId, { limit: 50 }); + 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); } - }) - .catch(err => { - reportError(err, { context: 'participants-fetchTeams', hackathonId }); - }); + + // Fetch participants with pagination + let allParticipants: any[] = []; + let page = 1; + let hasMore = true; + + while (hasMore) { + const participantsResponse = await getHackathonParticipants( + hackathonId, + { limit: 100, page } + ); + if (participantsResponse.success && participantsResponse.data) { + const newParticipants = + participantsResponse.data.participants || []; + allParticipants = [...allParticipants, ...newParticipants]; + hasMore = participantsResponse.data.pagination?.hasNext || false; + page++; + } else { + hasMore = false; + } + } + setApiParticipants(allParticipants); + } catch (err) { + reportError(err, { context: 'participants-fetchData', hackathonId }); + } finally { + setIsLoading(false); + } + }; + + fetchAllData(); } }, [hackathonId]); @@ -66,66 +98,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 +251,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 +271,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/types.ts b/lib/api/types.ts index 39010e57..d418161a 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -122,6 +122,7 @@ export interface User { rank?: number | null; submittedAt: string; hackathonId: string; + projectId: string; hackathon?: Hackathon; }>; joinedHackathons?: Array<{ diff --git a/lib/api/waitlist.ts b/lib/api/waitlist.ts index c16b9761..8a34d057 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 11261f04..e21a12c1 100644 --- a/lib/providers/hackathonProvider.tsx +++ b/lib/providers/hackathonProvider.tsx @@ -99,7 +99,7 @@ interface HackathonDataContextType { getHackathonById: (id: string) => Hackathon | undefined; getHackathonBySlug: (slug: string) => Promise; - setCurrentHackathon: (slug: string) => void; + setCurrentHackathon: (slug: string) => Promise; addDiscussion: (content: string) => Promise; addReply: (parentCommentId: string, content: string) => Promise; @@ -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); diff --git a/package-lock.json b/package-lock.json index 32c85f04..b7d49bcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "three": "^0.180.0", + "turndown": "^7.2.2", "tw-animate-css": "^1.3.6", "uuid": "^13.0.0", "vaul": "^1.1.2", @@ -137,6 +138,7 @@ "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", @@ -1609,6 +1611,12 @@ "langium": "^4.0.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@monogrid/gainmap-js": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", @@ -6352,6 +6360,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -8970,10 +8985,13 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -16839,6 +16857,15 @@ } } }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/package.json b/package.json index 8e73c10a..a557459e 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "three": "^0.180.0", + "turndown": "^7.2.2", "tw-animate-css": "^1.3.6", "uuid": "^13.0.0", "vaul": "^1.1.2", @@ -153,6 +154,7 @@ "@types/p-limit": "^2.1.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0",