Initial commit from v0
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function ConfirmEmailPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ email?: string }>
|
||||
}) {
|
||||
const { email } = await searchParams
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111] flex items-center justify-center relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(oklch(0.4 0 0) 1px, transparent 1px), linear-gradient(90deg, oklch(0.4 0 0) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-[480px] mx-4">
|
||||
<div className="h-1 w-full bg-[#d4f53c] rounded-t" />
|
||||
<div className="bg-[#1a1a1a] rounded-b px-10 py-10 text-center">
|
||||
<div className="mb-8">
|
||||
<span className="font-mono text-2xl font-bold">
|
||||
<span className="text-[#d4f53c]">{'{'}</span>
|
||||
<span className="text-white">hackerspace.sh</span>
|
||||
<span className="text-[#d4f53c]">{'}'}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-16 h-16 rounded-full bg-[#d4f53c]/10 border-2 border-[#d4f53c]/30 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-8 h-8 text-[#d4f53c]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-[11px] tracking-widest text-zinc-500 mb-4 uppercase">
|
||||
Check your email
|
||||
</p>
|
||||
|
||||
<p className="font-sans text-white text-lg font-semibold mb-3">
|
||||
Confirm your email address
|
||||
</p>
|
||||
|
||||
<p className="font-sans text-sm text-zinc-400 leading-relaxed mb-2">
|
||||
We sent a confirmation link to
|
||||
</p>
|
||||
{email && (
|
||||
<p className="font-mono text-sm text-[#d4f53c] mb-6">{email}</p>
|
||||
)}
|
||||
<p className="font-sans text-xs text-zinc-600 mb-8 leading-relaxed">
|
||||
Click the link in the email to activate your account and set up your space. It may take a minute to arrive. Check your spam folder if you don't see it.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-mono text-xs text-[#d4f53c] hover:underline"
|
||||
>
|
||||
Already confirmed? Sign in →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { createSpace, joinSpace } from '@/lib/auth-actions'
|
||||
|
||||
function generateInviteCode() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
const prefix = 'HSL'
|
||||
const year = new Date().getFullYear()
|
||||
let code = ''
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return `${prefix}-${year}-${code}`
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<'account' | 'space'>('account')
|
||||
const [isReturningUser, setIsReturningUser] = useState(false)
|
||||
const [mode, setMode] = useState<'create' | 'join' | null>(null)
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [spaceName, setSpaceName] = useState('')
|
||||
const [spaceSlug, setSpaceSlug] = useState('')
|
||||
const [spaceCity, setSpaceCity] = useState('')
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
setEmail(user.email || '')
|
||||
setFullName(user.user_metadata?.full_name || '')
|
||||
setIsReturningUser(true)
|
||||
setStep('space')
|
||||
}
|
||||
}
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
async function handleAccountSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!mode) { setError('Please select create or join a space'); return }
|
||||
setError('')
|
||||
setStep('space')
|
||||
}
|
||||
|
||||
async function handleSpaceSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!mode) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
// Returning user — call server actions (runs server-side, bypasses any client RLS issues)
|
||||
if (isReturningUser) {
|
||||
const result = mode === 'create'
|
||||
? await createSpace({ spaceName, spaceSlug, spaceCity, displayName: fullName })
|
||||
: await joinSpace({ inviteCode, displayName: fullName })
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
// New user — sign up; trigger will create space/member
|
||||
const supabase = createClient()
|
||||
|
||||
if (mode === 'join') {
|
||||
const { data: space, error: lookupErr } = await supabase
|
||||
.from('spaces')
|
||||
.select('id')
|
||||
.eq('invite_code', inviteCode.trim().toUpperCase())
|
||||
.single()
|
||||
if (lookupErr || !space) { setError('Invalid invite code'); setLoading(false); return }
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
full_name: fullName,
|
||||
space_action: mode,
|
||||
...(mode === 'create'
|
||||
? { space_name: spaceName, space_slug: spaceSlug, space_city: spaceCity, invite_code: generateInviteCode() }
|
||||
: { join_invite_code: inviteCode.trim().toUpperCase() }
|
||||
),
|
||||
}
|
||||
|
||||
const { data: authData, error: authError } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
data: metadata,
|
||||
},
|
||||
})
|
||||
|
||||
if (authError) { setError(authError.message); setLoading(false); return }
|
||||
|
||||
if (authData.session) {
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/signup/confirm?email=' + encodeURIComponent(email))
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111] flex items-center justify-center relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(oklch(0.4 0 0) 1px, transparent 1px), linear-gradient(90deg, oklch(0.4 0 0) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-[860px] mx-4">
|
||||
<div className="h-1 w-full bg-[#d4f53c] rounded-t" />
|
||||
<div className="bg-[#1a1a1a] rounded-b px-10 py-10">
|
||||
<div className="mb-8">
|
||||
<span className="font-mono text-2xl font-bold">
|
||||
<span className="text-[#d4f53c]">{'{'}</span>
|
||||
<span className="text-white">hackerspace.sh</span>
|
||||
<span className="text-[#d4f53c]">{'}'}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-[11px] tracking-widest text-zinc-500 mb-6 uppercase">
|
||||
{step === 'account' ? 'Create your account' : mode === 'create' ? 'Set up your Space' : mode === 'join' ? 'Join a Space' : 'Choose an option'}
|
||||
{isReturningUser && step === 'space' && (
|
||||
<span className="ml-2 normal-case text-zinc-600">({email})</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{step === 'account' ? (
|
||||
<form onSubmit={handleAccountSubmit}>
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={e => setFullName(e.target.value)}
|
||||
placeholder="Moheeb Zara"
|
||||
required
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="min 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase mb-3">
|
||||
What are you doing?
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('create')}
|
||||
className={`p-6 rounded border-2 transition text-left ${
|
||||
mode === 'create'
|
||||
? 'border-[#d4f53c] bg-[#232323]'
|
||||
: 'border-[#333] bg-[#1a1a1a] hover:border-[#555]'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<svg className="w-8 h-8 text-[#d4f53c]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-mono font-bold text-white mb-1">Create a Space</p>
|
||||
<p className="font-mono text-xs text-zinc-500">set up a new hackerspace</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('join')}
|
||||
className={`p-6 rounded border-2 transition text-left ${
|
||||
mode === 'join'
|
||||
? 'border-[#d4f53c] bg-[#232323]'
|
||||
: 'border-[#333] bg-[#1a1a1a] hover:border-[#555]'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<svg className="w-8 h-8 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-mono font-bold text-white mb-1">Join a Space</p>
|
||||
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!mode}
|
||||
className="w-full bg-[#d4f53c] text-black font-mono text-sm font-bold tracking-widest uppercase py-3 rounded hover:bg-[#c5e635] transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue →
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSpaceSubmit}>
|
||||
{/* Mode picker - show inline for returning users who haven't selected yet */}
|
||||
{!mode && (
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('create')}
|
||||
className="p-6 rounded border-2 border-[#333] bg-[#1a1a1a] hover:border-[#d4f53c] transition text-left"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<svg className="w-8 h-8 text-[#d4f53c]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-mono font-bold text-white mb-1">Create a Space</p>
|
||||
<p className="font-mono text-xs text-zinc-500">set up a new hackerspace</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('join')}
|
||||
className="p-6 rounded border-2 border-[#333] bg-[#1a1a1a] hover:border-[#d4f53c] transition text-left"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<svg className="w-8 h-8 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-mono font-bold text-white mb-1">Join a Space</p>
|
||||
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'create' ? (
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
Space Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spaceName}
|
||||
onChange={e => {
|
||||
setSpaceName(e.target.value)
|
||||
setSpaceSlug(
|
||||
e.target.value.toLowerCase().replace(/\s+/g, '').replace(/[^a-z0-9]/g, '')
|
||||
)
|
||||
}}
|
||||
placeholder="HeatSync Labs"
|
||||
required
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spaceSlug}
|
||||
onChange={e => setSpaceSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, ''))}
|
||||
placeholder="heatsynclabs"
|
||||
required
|
||||
pattern="[a-z0-9]+"
|
||||
title="Lowercase letters and numbers only"
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
City / Location (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spaceCity}
|
||||
onChange={e => setSpaceCity(e.target.value)}
|
||||
placeholder="Mesa, AZ"
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-[#232323] border border-[#333] rounded p-4">
|
||||
<p className="font-mono text-[10px] text-zinc-500 mb-1">You'll be the admin of this space.</p>
|
||||
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, random) will be created automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||
Invite Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={e => setInviteCode(e.target.value.toUpperCase())}
|
||||
placeholder="HSL-2025-XXXX"
|
||||
required
|
||||
className="w-full bg-[#232323] border border-[#333] text-white font-mono text-sm rounded px-3 py-2.5 placeholder:text-zinc-600 focus:outline-none focus:border-[#d4f53c] transition"
|
||||
/>
|
||||
<p className="font-mono text-[10px] text-zinc-600 mt-1.5">Ask your space admin for the invite code from Settings.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isReturningUser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('account'); setError('') }}
|
||||
className="px-6 bg-[#232323] border border-[#333] text-white font-mono text-sm py-3 rounded hover:border-[#555] transition"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{mode && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 bg-[#d4f53c] text-black font-mono text-sm font-bold tracking-widest uppercase py-3 rounded hover:bg-[#c5e635] transition disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Please wait...' : mode === 'create' ? 'Create Space →' : 'Join Space →'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!isReturningUser && (
|
||||
<p className="text-center font-mono text-xs text-zinc-600 mt-6">
|
||||
already have an account?{' '}
|
||||
<Link href="/login" className="text-[#d4f53c] hover:underline">
|
||||
sign in →
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user