diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index cab285df2..000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "boundless-v1", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 3000 - } - ] -} diff --git a/.env.example b/.env.example index 34910cd13..50e04a865 100644 --- a/.env.example +++ b/.env.example @@ -22,10 +22,12 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID="" NEXT_PUBLIC_HORIZON_PUBLIC_URL="https://horizon.stellar.org" NEXT_PUBLIC_HORIZON_TESTNET_URL="https://horizon-testnet.stellar.org" NEXT_PUBLIC_STELLAR_NETWORK="testnet" -# Whitelisted USDC SAC (Soroban contract C-address) used as the escrow tokenAddress. -# Must match the boundless-events contract's whitelisted token (see backend admin runbook). -NEXT_PUBLIC_USDC_TOKEN_CONTRACT_TESTNET="CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" -NEXT_PUBLIC_USDC_TOKEN_CONTRACT_PUBLIC="" NEXT_PUBLIC_TRUSTLESS_WORK_API_KEY="" NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="your_wallet_connect_project_id" +# Error reporting (optional). When set, errors are sent to Sentry. +NEXT_PUBLIC_SENTRY_DSN="" +SENTRY_DSN="" +SENTRY_ORG="" +SENTRY_PROJECT="boundless-next" +SENTRY_AUTH_TOKEN="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" NODE_ENV="dev" \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index abfcad5e9..a0b25c772 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,14 +1,23 @@ -#!/usr/bin/env sh -set -e + echo "๐Ÿš€ Running pre-push checks..." +# Run all tests (if you have them) +# echo "๐Ÿงช Running tests..." +# npm test + +# Run security audit +echo "๐Ÿ”’ Running security audit..." +npm audit --omit=dev --audit-level=high -echo "๐Ÿ”’ Auditing dependencies (non-blocking)..." -npm audit --omit=dev --audit-level=high || true +# Run build check one more time +# echo "๐Ÿ—๏ธ Final build check..." +# npm run build +# Check for any uncommitted changes if ! git diff-index --quiet HEAD --; then - echo "โš ๏ธ You have uncommitted changes โ€” they won't be included in this push." + echo "โš ๏ธ Warning: You have uncommitted changes." + echo " Consider committing them before pushing." fi -echo "โœ… Pre-push checks passed!" +echo "โœ… Pre-push checks completed!" diff --git a/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx b/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx deleted file mode 100644 index c022905fb..000000000 --- a/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx +++ /dev/null @@ -1,383 +0,0 @@ -'use client'; - -import { use } from 'react'; -import Link from 'next/link'; -import { - ArrowLeft, - CalendarDays, - CheckCircle2, - Clock, - ExternalLink, - FileText, - AlertTriangle, - RefreshCw, -} from 'lucide-react'; - -import { useCampaign, useMilestone } from '@/features/crowdfunding'; -import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; -import { milestoneState, TONE_PILL } from '@/lib/crowdfunding/status'; - -interface PageProps { - params: Promise<{ slug: string; id: string }>; -} - -interface MilestoneDetail { - id: string; - title?: string | null; - name?: string | null; - description?: string | null; - deliverable?: string | null; - successCriteria?: string | null; - fundingPercentage?: number | null; - amount?: number | null; - expectedDeliveryDate?: string | null; - endDate?: string | null; - startDate?: string | null; - orderIndex?: number | null; - reviewStatus?: string | null; - submittedAt?: string | null; - proofOfWorkFiles?: string[]; - proofOfWorkLinks?: string[]; - submissionNotes?: string | null; - reviewedAt?: string | null; - rejectionReason?: string | null; - rejectionFeedback?: string | null; - resubmissionDeadline?: string | null; - completedAt?: string | null; - claimedAt?: string | null; - isOverdue?: boolean; - daysRemaining?: number | null; -} - -function formatDate(dateStr?: string | null): string | null { - if (!dateStr) return null; - const d = new Date(dateStr); - if (Number.isNaN(d.getTime())) return null; - return d.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); -} - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

- {title} -

- {children} -
- ); -} - -function MetaRow({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
- {label} - {value} -
- ); -} - -export default function PublicMilestoneDetailPage({ params }: PageProps) { - const { slug, id } = use(params); - const { data: campaign, isLoading: campaignLoading } = useCampaign(slug); - const { data: milestoneRaw, isLoading: milestoneLoading } = useMilestone( - campaign?.id ?? null, - id - ); - - const isLoading = campaignLoading || (!!campaign?.id && milestoneLoading); - - if (isLoading) { - return ( -
- Loading... -
- ); - } - - if (!campaign || !milestoneRaw) { - return ( -
- Milestone not found. -
- ); - } - - const m = milestoneRaw as MilestoneDetail; - const milestones = campaign.milestones ?? []; - const idx = milestones.findIndex(ms => ms.id === id); - const orderNumber = idx >= 0 ? idx + 1 : null; - - const st = milestoneState(m.reviewStatus, m.claimedAt); - const dueDate = formatDate(m.expectedDeliveryDate ?? m.endDate); - const submittedAt = formatDate(m.submittedAt); - const reviewedAt = formatDate(m.reviewedAt); - - const totalAmount = milestones.reduce((s, ms) => s + (ms.amount ?? 0), 0); - const pct = - totalAmount > 0 && m.amount != null - ? Math.round((m.amount / totalAmount) * 100) - : null; - - const hasEvidence = - m.submittedAt || - (m.proofOfWorkLinks?.length ?? 0) > 0 || - (m.proofOfWorkFiles?.length ?? 0) > 0 || - m.submissionNotes; - - const hasReviewOutcome = - m.reviewedAt || m.rejectionFeedback || m.rejectionReason || m.claimedAt; - - const isRejected = - m.reviewStatus === 'REJECTED' || m.reviewStatus === 'RESUBMISSION_REQUIRED'; - - return ( -
- {/* Nav bar */} -
-
- - - All milestones - -
-
- -
- {/* Header */} -
-
-
- {campaign.project.title} - {orderNumber && ( - <> - / - Milestone {orderNumber} - - )} -
-

- {m.title ?? m.name ?? 'Untitled milestone'} -

-
- - {st.label} - -
- - {/* Key facts */} -
-
- {m.amount != null && ( - - ${m.amount.toLocaleString()} USDC - {pct != null && ( - - {pct}% of total - - )} - - } - /> - )} - {dueDate && ( - - - {dueDate} - {m.isOverdue && ( - Overdue - )} - - } - /> - )} - {submittedAt && ( - - - {submittedAt} - - } - /> - )} - {reviewedAt && ( - - - {reviewedAt} - - } - /> - )} -
-
- - {/* Description */} - {m.description && ( -
-

- {m.description} -

-
- )} - - {/* Deliverable */} - {m.deliverable && ( -
-

- {m.deliverable} -

-
- )} - - {/* Success criteria */} - {m.successCriteria && ( -
-

- {m.successCriteria} -

-
- )} - - {/* Evidence submitted by the builder */} - {hasEvidence && ( -
- {m.submissionNotes && ( -
-

Notes

-

- {m.submissionNotes} -

-
- )} - - {(m.proofOfWorkLinks?.length ?? 0) > 0 && ( -
-

Links

-
    - {m.proofOfWorkLinks!.map((link, i) => ( -
  • - - - {link} - -
  • - ))} -
-
- )} - - {(m.proofOfWorkFiles?.length ?? 0) > 0 && ( -
-

Files

- -
- )} -
- )} - - {/* Review outcome */} - {hasReviewOutcome && ( -
- {m.claimedAt && ( -
- -
-

- Paid out -

- {m.amount != null && ( -

- ${m.amount.toLocaleString()} USDC -

- )} - {formatDate(m.claimedAt) && ( -

- {formatDate(m.claimedAt)} -

- )} -
-
- )} - - {isRejected && (m.rejectionReason || m.rejectionFeedback) && ( -
- -
-

- {m.reviewStatus === 'RESUBMISSION_REQUIRED' - ? 'Resubmission required' - : 'Not accepted'} -

- {m.rejectionReason && ( -

- {m.rejectionReason} -

- )} - {m.rejectionFeedback && ( -

- {m.rejectionFeedback} -

- )} - {m.resubmissionDeadline && ( -
- - Resubmit by {formatDate(m.resubmissionDeadline)} -
- )} -
-
- )} -
- )} -
-
- ); -} diff --git a/app/(landing)/crowdfunding/[slug]/milestones/page.tsx b/app/(landing)/crowdfunding/[slug]/milestones/page.tsx deleted file mode 100644 index 815b8e66c..000000000 --- a/app/(landing)/crowdfunding/[slug]/milestones/page.tsx +++ /dev/null @@ -1,189 +0,0 @@ -'use client'; - -import { use } from 'react'; -import Link from 'next/link'; -import { ArrowLeft, CalendarDays, ChevronRight } from 'lucide-react'; - -import { useCampaign } from '@/features/crowdfunding'; -import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; -import { - campaignStatus, - milestoneState, - TONE_PILL, -} from '@/lib/crowdfunding/status'; - -interface PageProps { - params: Promise<{ slug: string }>; -} - -function formatDate(dateStr?: string): string | null { - if (!dateStr) return null; - const d = new Date(dateStr); - if (Number.isNaN(d.getTime())) return null; - return d.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); -} - -export default function PublicMilestonesPage({ params }: PageProps) { - const { slug } = use(params); - const { data: campaign, isLoading } = useCampaign(slug); - - if (isLoading) { - return ( -
- Loading... -
- ); - } - - if (!campaign) { - return ( -
- Campaign not found. -
- ); - } - - const status = campaignStatus(campaign.v2Status); - const milestones = campaign.milestones ?? []; - const totalAmount = milestones.reduce((s, m) => s + (m.amount ?? 0), 0); - - return ( -
-
-
- - - Back to campaign - -
-
- -
- {/* Campaign header */} -
- {campaign.project.logo && ( - // eslint-disable-next-line @next/next/no-img-element - {campaign.project.title} - )} -
-

- {campaign.project.title} -

-
- - {status.label} - -
- -
-

- Milestones{' '} - - ({milestones.length}) - -

-

- Funds are released one stage at a time as each milestone is - delivered and approved. -

-
- - {milestones.length === 0 ? ( -

No milestones listed.

- ) : ( -
- {milestones.map((m, idx) => { - const st = milestoneState(m.reviewStatus); - const planned = formatDate(m.endDate); - const pct = - totalAmount > 0 - ? Math.round(((m.amount ?? 0) / totalAmount) * 100) - : 0; - - return m.id ? ( - - {/* Number chip */} -
- {idx + 1} -
- -
-

- {m.title || m.name} -

- {m.description && ( -

- {m.description} -

- )} -
- {m.amount != null && ( - ${m.amount.toLocaleString()} USDC - )} - {pct > 0 && {pct}% of total} - {planned && ( - - - {planned} - - )} -
-
- -
- - {st.label} - - -
- - ) : ( -
-
- {idx + 1} -
-
-

- {m.title || m.name} -

-
- - {st.label} - -
- ); - })} -
- )} -
-
- ); -} diff --git a/app/(landing)/crowdfunding/[slug]/page.tsx b/app/(landing)/crowdfunding/[slug]/page.tsx deleted file mode 100644 index 17aacf54f..000000000 --- a/app/(landing)/crowdfunding/[slug]/page.tsx +++ /dev/null @@ -1,625 +0,0 @@ -'use client'; - -import { useState, use } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { - ArrowLeft, - Users, - Github, - Globe, - Video, - ExternalLink, - ShieldCheck, - CalendarDays, -} from 'lucide-react'; - -import { useCampaign } from '@/features/crowdfunding'; -import type { CrowdfundingContributor } from '@/features/crowdfunding'; -import { - ContributeSheet, - MIN_CONTRIBUTION, -} from '@/components/crowdfunding/ContributeSheet'; -import { VotePanel } from '@/components/crowdfunding/VotePanel'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; -import { - campaignStatus, - milestoneState, - TONE_PILL, -} from '@/lib/crowdfunding/status'; -import { getTransactionExplorerUrl } from '@/lib/wallet-utils'; -import { useAuthStatus } from '@/hooks/use-auth'; - -interface PageProps { - params: Promise<{ slug: string }>; -} - -function daysLeft(dateStr?: string): number | null { - if (!dateStr) return null; - const diff = new Date(dateStr).getTime() - Date.now(); - return Math.max(0, Math.ceil(diff / 86_400_000)); -} - -function formatDate(dateStr?: string): string | null { - if (!dateStr) return null; - const d = new Date(dateStr); - if (Number.isNaN(d.getTime())) return null; - return d.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); -} - -function pct(raised: number, goal: number): number { - if (!goal) return 0; - return Math.min(100, Math.round((raised / goal) * 100)); -} - -function SupporterRow({ c }: { c: CrowdfundingContributor }) { - const isAnon = !c.username && !c.name; - const dateLabel = new Date(c.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - return ( -
-
- {c.image ? ( - {c.name - ) : ( -
- {isAnon - ? '?' - : (c.name || c.username || 'S').charAt(0).toUpperCase()} -
- )} -
-
-

- {isAnon ? 'Anonymous supporter' : c.name || c.username} -

- {c.message && ( -

{c.message}

- )} -
-
-

- ${c.amount.toLocaleString()} -

- {c.transactionHash ? ( - - {dateLabel} - - - ) : ( -

{dateLabel}

- )} -
-
- ); -} - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

{title}

- {children} -
- ); -} - -export default function PublicCampaignPage({ params }: PageProps) { - const { slug } = use(params); - const { data: campaign, isLoading } = useCampaign(slug); - const [sheetOpen, setSheetOpen] = useState(false); - const { user } = useAuthStatus(); - - if (isLoading) { - return ( -
- Loading... -
- ); - } - - if (!campaign) { - return ( -
- Campaign not found. -
- ); - } - - const project = campaign.project; - const raised = campaign.fundingRaised ?? 0; - const goal = campaign.fundingGoal ?? 0; - const progress = pct(raised, goal); - const days = daysLeft(campaign.fundingEndDate); - const closeDate = formatDate(campaign.fundingEndDate); - const supporters = campaign.contributors ?? []; - - const isFunding = campaign.v2Status === 'FUNDING'; - const isVoting = campaign.v2Status === 'VOTING'; - const isComplete = campaign.v2Status === 'COMPLETED'; - const showFundingStats = isFunding || isComplete; - // Funded once less than the contract minimum remains (the last sliver can't - // be filled per the on-chain floor), so we stop offering "Back this project". - const isFullyFunded = - isFunding && goal > 0 && goal - raised < MIN_CONTRIBUTION; - - const completedMilestones = campaign.milestones.filter(m => - Boolean(m.claimedAt) - ).length; - - const baseStatus = campaignStatus(campaign.v2Status); - const status = isFullyFunded - ? { - label: 'Fully Funded', - tone: 'success' as const, - description: baseStatus.description, - } - : baseStatus; - - const isOwnerOrTeam = - !!user?.id && - (user.id === project.creatorId || - campaign.team.some(m => m.id === user.id)); - // Statuses the public is allowed to observe. All others are pre-launch or - // terminal-negative and should not expose internal status copy to strangers. - const isPublicStatus = - campaign.v2Status === 'VOTING' || - campaign.v2Status === 'FUNDING' || - campaign.v2Status === 'PAUSED' || - campaign.v2Status === 'COMPLETED'; - - return ( -
- {/* Back link */} -
-
- - - All campaigns - -
-
- - {/* Hero */} - {project.banner ? ( -
- {project.title} -
-
- ) : ( -
- )} - -
- {/* Title row */} -
- {project.logo && ( -
- {project.title} -
- )} -
-
-

- {project.title} -

- - {status.label} - -
- {project.tagline && ( -

{project.tagline}

- )} -
-
- -
- {/* Main content โ€” single scroll */} -
- {/* Story */} -
-
- {project.vision || project.description || ( - - No description provided. - - )} -
- {(project.githubUrl || - project.projectWebsite || - project.demoVideo) && ( -
- {project.githubUrl && ( - - - Code - - - )} - {project.projectWebsite && ( - - - Website - - - )} - {project.demoVideo && ( - - - )} -
- )} -
- - {/* Milestones */} -
0 - ? `Milestones (${completedMilestones} / ${campaign.milestones.length} delivered)` - : 'Milestones' - } - > -

- The plan is delivered in stages. Funds are released to the team - one stage at a time, as each is delivered and approved by a - reviewer. -

- {campaign.milestones.length === 0 ? ( -

No milestones listed.

- ) : ( -
- {campaign.milestones.map((m, idx) => { - const st = milestoneState(m.reviewStatus, m.claimedAt); - const planned = formatDate(m.endDate); - const inner = ( - <> -
-
-

- {idx + 1}. {m.title || m.name} -

- {m.description && ( -

- {m.description} -

- )} -
-
- {showFundingStats && m.amount != null && ( - - ${m.amount.toLocaleString()} - - )} - - {st.label} - -
-
- {planned && ( -

- - Planned for {planned} -

- )} - - ); - return m.id ? ( - - {inner} - - ) : ( -
- {inner} -
- ); - })} -
- )} -
- - {/* Team */} - {campaign.team.length > 0 && ( -
-
- {campaign.team.map((m, idx) => ( -
-
- {m.image ? ( - {m.name} - ) : ( -
- {m.name.charAt(0).toUpperCase()} -
- )} -
-
-

- {m.name} -

-

{m.role}

-
-
- ))} -
-
- )} - - {/* Supporters */} -
- {supporters.length === 0 ? ( -
- -

- No supporters yet. - {isFunding ? ' Be the first to back this.' : ''} -

-
- ) : ( -
- {supporters.map((c, i) => ( - - ))} -
- )} -
-
- - {/* Sticky sidebar โ€” funding + action */} -
-
-
- {showFundingStats ? ( - <> -
-
-
-
-
- {progress}% funded - {isFunding && days !== null && ( - - {days} {days === 1 ? 'day' : 'days'} left - - )} -
-
-
-

- ${raised.toLocaleString()} -

-

- raised of ${goal.toLocaleString()} goal -

-
-

- {supporters.length}{' '} - {supporters.length === 1 ? 'supporter' : 'supporters'} - {isFunding && closeDate ? ` ยท closes ${closeDate}` : ''} -

- - ) : ( -
-
-

- ${goal.toLocaleString()}{' '} - - {campaign.fundingCurrency ?? 'USDC'} - -

-

funding goal

-
- {campaign.milestones.length > 0 && ( -

- {campaign.milestones.length}{' '} - {campaign.milestones.length === 1 - ? 'milestone' - : 'milestones'}{' '} - planned -

- )} -
- )} - - {/* Primary action by state */} - {isOwnerOrTeam ? ( - // Owner/team: show their private status info; no voting or funding actions. -
-

{status.label}

-

- {status.description} -

-
- ) : isFunding && !isFullyFunded ? ( - <> - -

- - Your support is held safely and released to the team as - each milestone is delivered and approved. -

- - ) : isFullyFunded ? ( -
-

- Fully funded -

-

- This campaign reached its goal. Thanks to all{' '} - {supporters.length}{' '} - {supporters.length === 1 ? 'supporter' : 'supporters'}. -

-
- ) : isVoting ? ( - - ) : isPublicStatus ? ( -
-

{status.label}

-

- {status.description} -

-
- ) : ( - // Pre-launch or terminal state accessed via direct URL; not in public listing. -
-

- Not yet open -

-

- This campaign is not yet available to the public. -

-
- )} -
- - {/* Creator */} - {project.creator && ( -
-

- Creator -

-
-
- {project.creator.image ? ( - {project.creator.name} - ) : ( -
- {(project.creator.name || 'C') - .charAt(0) - .toUpperCase()} -
- )} -
-
-

- {project.creator.name} -

- {project.creator.username && ( -

- @{project.creator.username} -

- )} -
-
-
- )} -
-
-
-
- - {!isOwnerOrTeam && ( - - )} -
- ); -} diff --git a/app/(landing)/crowdfunding/new/page.tsx b/app/(landing)/crowdfunding/new/page.tsx deleted file mode 100644 index 639161779..000000000 --- a/app/(landing)/crowdfunding/new/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Metadata } from 'next'; -import { AuthGuard } from '@/components/auth'; -import { Suspense } from 'react'; -import NewCampaignWizard from '@/components/crowdfunding/new/NewCampaignWizard'; - -export const metadata: Metadata = { - title: 'New Campaign | Boundless', - description: 'Create a new crowdfunding campaign on Boundless', -}; - -export default function NewCampaignPage() { - return ( - Authenticating...
} - > - Loading...
}> - - - - ); -} diff --git a/app/(landing)/crowdfunding/page.tsx b/app/(landing)/crowdfunding/page.tsx deleted file mode 100644 index b46b0855b..000000000 --- a/app/(landing)/crowdfunding/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import CrowdfundingExplore from '@/features/crowdfunding/components/CrowdfundingExplore'; -import CrowdfundingPageHero from '@/features/crowdfunding/components/CrowdfundingPageHero'; - -export default function CrowdfundingPage() { - return ( -
-
-
- - -
-
-
- ); -} diff --git a/app/(landing)/hackathons/[slug]/components/AccessGate.tsx b/app/(landing)/hackathons/[slug]/components/AccessGate.tsx deleted file mode 100644 index 47821b75f..000000000 --- a/app/(landing)/hackathons/[slug]/components/AccessGate.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Loader2, Lock } from 'lucide-react'; -import { toast } from 'sonner'; -import { BoundlessButton } from '@/components/buttons'; -import { Input } from '@/components/ui/input'; -import { verifyHackathonAccess } from '@/lib/api/hackathon'; - -/** - * Shown when a private hackathon's public page is opened without access. On a - * correct password we store a slug-keyed cookie and refresh; the server then - * reads the cookie, forwards the token, and renders the unlocked page. - */ -export default function AccessGate({ - slug, - name, - description, -}: { - slug: string; - name: string; - description?: string | null; -}) { - const router = useRouter(); - const [password, setPassword] = useState(''); - const [submitting, setSubmitting] = useState(false); - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!password.trim()) return; - setSubmitting(true); - try { - const { accessToken } = await verifyHackathonAccess( - slug, - password.trim() - ); - if (!accessToken) throw new Error('No access token'); - document.cookie = `hx_access_${slug}=${accessToken}; path=/; max-age=86400; samesite=lax`; - router.refresh(); - } catch (err) { - const msg = (err as { response?: { data?: { message?: string } } }) - ?.response?.data?.message; - toast.error(msg || 'That password is not right. Try again.'); - setSubmitting(false); - } - }; - - return ( -
-
-
- -
-

{name}

-

- {description || 'This hackathon is private.'} Enter the password to - view it. -

-
- setPassword(e.target.value)} - placeholder='Password' - autoFocus - className='border-gray-700 bg-black text-center text-white' - /> - - - {submitting ? : null} - Unlock - - -
-
-
- ); -} diff --git a/app/(landing)/hackathons/[slug]/components/RegistrationQuestionsDialog.tsx b/app/(landing)/hackathons/[slug]/components/RegistrationQuestionsDialog.tsx deleted file mode 100644 index fc78e0540..000000000 --- a/app/(landing)/hackathons/[slug]/components/RegistrationQuestionsDialog.tsx +++ /dev/null @@ -1,191 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { BoundlessButton } from '@/components/buttons'; -import { cn } from '@/lib/utils'; -import { - listPublicCustomQuestions, - type CustomQuestion, -} from '@/lib/api/hackathons/custom-questions'; - -/** - * Cached fetch of a hackathon's REGISTRATION-scope custom questions. The - * register buttons use this to decide whether registration needs a form - * (questions present) or can join directly (none). - */ -export function useRegistrationQuestions(slug: string) { - return useQuery({ - queryKey: ['hackathon', 'custom-questions', slug, 'REGISTRATION'], - queryFn: () => listPublicCustomQuestions(slug, 'REGISTRATION'), - enabled: !!slug, - staleTime: 60_000, - }); -} - -interface RegistrationQuestionsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - questions: CustomQuestion[]; - submitting?: boolean; - /** Persist + join. Resolve to close the dialog; reject to keep it open. */ - onSubmit: (answers: Record) => Promise; -} - -export default function RegistrationQuestionsDialog({ - open, - onOpenChange, - questions, - submitting, - onSubmit, -}: RegistrationQuestionsDialogProps) { - const [answers, setAnswers] = useState>({}); - - // Reset the form each time the dialog opens so a cancelled attempt does not - // leak into the next one. - useEffect(() => { - if (open) setAnswers({}); - }, [open]); - - const setAnswer = (id: string, val: string | string[]) => - setAnswers(prev => ({ ...prev, [id]: val })); - - const handleSubmit = async () => { - for (const q of questions) { - if (!q.required) continue; - const v = answers[q.id]; - const empty = Array.isArray(v) - ? v.length === 0 - : !v || String(v).trim() === ''; - if (empty) { - toast.error(`"${q.label}" is required.`); - return; - } - } - await onSubmit(answers); - }; - - return ( - - - - A few questions before you register - - The organizer asks these when you join. - - - -
- {questions.map(q => { - const options = Array.isArray(q.options) ? q.options : []; - const raw = answers[q.id]; - const strVal = typeof raw === 'string' ? raw : ''; - const arrVal = Array.isArray(raw) ? raw : []; - return ( -
- - {q.type === 'LONG' ? ( -