diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..cab285df2 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "boundless-v1", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.env.example b/.env.example index 50e04a865..34910cd13 100644 --- a/.env.example +++ b/.env.example @@ -22,12 +22,10 @@ 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 a0b25c772..abfcad5e9 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,23 +1,14 @@ - +#!/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 -# Run build check one more time -# echo "๐Ÿ—๏ธ Final build check..." -# npm run build +echo "๐Ÿ”’ Auditing dependencies (non-blocking)..." +npm audit --omit=dev --audit-level=high || true -# Check for any uncommitted changes if ! git diff-index --quiet HEAD --; then - echo "โš ๏ธ Warning: You have uncommitted changes." - echo " Consider committing them before pushing." + echo "โš ๏ธ You have uncommitted changes โ€” they won't be included in this push." fi -echo "โœ… Pre-push checks completed!" +echo "โœ… Pre-push checks passed!" diff --git a/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx b/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx new file mode 100644 index 000000000..c022905fb --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/milestones/[id]/page.tsx @@ -0,0 +1,383 @@ +'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 new file mode 100644 index 000000000..815b8e66c --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/milestones/page.tsx @@ -0,0 +1,189 @@ +'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 new file mode 100644 index 000000000..17aacf54f --- /dev/null +++ b/app/(landing)/crowdfunding/[slug]/page.tsx @@ -0,0 +1,625 @@ +'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 new file mode 100644 index 000000000..639161779 --- /dev/null +++ b/app/(landing)/crowdfunding/new/page.tsx @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..b46b0855b --- /dev/null +++ b/app/(landing)/crowdfunding/page.tsx @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..47821b75f --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/AccessGate.tsx @@ -0,0 +1,83 @@ +'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 new file mode 100644 index 000000000..fc78e0540 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/components/RegistrationQuestionsDialog.tsx @@ -0,0 +1,191 @@ +'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' ? ( +