mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Add initial account setup form
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import AccountForm from "@/components/Account/AccountForm";
|
||||
import AccountSetupForm from "../../components/Account/AccountSetupForm";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -10,12 +11,27 @@ export default async function AccountPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Fetch profile server-side to decide which form to render and pass to client
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, username, website, avatar_url')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
const needsInitialSetup = !profile || profile.username == null
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-lg px-6 py-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Your account</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">Manage profile details and avatar.</p>
|
||||
<div className="mt-8 card p-6">
|
||||
<AccountForm user={user} />
|
||||
<div className="mx-auto my-auto max-w-screen-lg px-6 py-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">
|
||||
{needsInitialSetup ? 'Choose a unique username to get started. You can update other details later.' : 'Manage profile details and avatar.'}
|
||||
</p>
|
||||
<div className="mt-8 card p-6 max-w-md">
|
||||
{needsInitialSetup ? (
|
||||
<AccountSetupForm user={user} />
|
||||
) : (
|
||||
<AccountForm user={user} profile={profile} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -185,3 +185,11 @@ body {
|
||||
--tw-prose-hr: var(--border);
|
||||
--tw-prose-captions: color-mix(in oklab, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scrollbar-color-muted {
|
||||
scrollbar-color: var(--muted) transparent;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/utils/supabase/client'
|
||||
import { type User } from '@supabase/supabase-js'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
export default function AccountForm({ user }: { user: User | null }) {
|
||||
type Profile = {
|
||||
full_name: string | null
|
||||
username: string | null
|
||||
website: string | null
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
export default function AccountForm({ user, profile }: { user: User | null, profile: Profile | null }) {
|
||||
const supabase = createClient()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fullname, setFullname] = useState<string | null>(null)
|
||||
const [username, setUsername] = useState<string | null>(null)
|
||||
const [website, setWebsite] = useState<string | null>(null)
|
||||
const [avatar_url, setAvatarUrl] = useState<string | null>(null)
|
||||
const [initialProfile, setInitialProfile] = useState<{
|
||||
fullname: string | null
|
||||
username: string | null
|
||||
website: string | null
|
||||
avatar_url: string | null
|
||||
}>({ fullname: null, username: null, website: null, avatar_url: null })
|
||||
|
||||
const getProfile = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`full_name, username, website, avatar_url`)
|
||||
.eq('id', user?.id)
|
||||
.single()
|
||||
|
||||
if (error && status !== 406) {
|
||||
console.log(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setFullname(data.full_name)
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
setInitialProfile({
|
||||
fullname: data.full_name,
|
||||
username: data.username,
|
||||
website: data.website,
|
||||
avatar_url: data.avatar_url,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error loading user data!')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [user, supabase])
|
||||
|
||||
useEffect(() => {
|
||||
getProfile()
|
||||
}, [user, getProfile])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fullname, setFullname] = useState<string | null>(profile?.full_name ?? null)
|
||||
const [username] = useState<string | null>(profile?.username ?? null)
|
||||
const [website, setWebsite] = useState<string | null>(profile?.website ?? null)
|
||||
const [avatar_url, setAvatarUrl] = useState<string | null>(profile?.avatar_url ?? null)
|
||||
const [initialProfile, setInitialProfile] = useState<Profile>({
|
||||
full_name: profile?.full_name ?? null,
|
||||
username: profile?.username ?? null,
|
||||
website: profile?.website ?? null,
|
||||
avatar_url: profile?.avatar_url ?? null,
|
||||
})
|
||||
|
||||
async function updateProfile({
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
fullname,
|
||||
}: {
|
||||
username: string | null
|
||||
fullname: string | null
|
||||
website: string | null
|
||||
avatar_url: string | null
|
||||
@@ -74,13 +41,12 @@ export default function AccountForm({ user }: { user: User | null }) {
|
||||
const { error } = await supabase.from('profiles').upsert({
|
||||
id: user?.id as string,
|
||||
full_name: fullname,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
if (error) throw error
|
||||
setInitialProfile({ fullname, username, website, avatar_url })
|
||||
setInitialProfile({ full_name: fullname, username, website, avatar_url })
|
||||
alert('Profile updated!')
|
||||
} catch (error) {
|
||||
alert('Error updating the data!')
|
||||
@@ -91,8 +57,7 @@ export default function AccountForm({ user }: { user: User | null }) {
|
||||
|
||||
const normalize = (v: string | null | undefined) => v ?? ''
|
||||
const hasChanges =
|
||||
normalize(fullname) !== normalize(initialProfile.fullname) ||
|
||||
normalize(username) !== normalize(initialProfile.username) ||
|
||||
normalize(fullname) !== normalize(initialProfile.full_name) ||
|
||||
normalize(website) !== normalize(initialProfile.website) ||
|
||||
normalize(avatar_url) !== normalize(initialProfile.avatar_url)
|
||||
|
||||
@@ -105,7 +70,7 @@ export default function AccountForm({ user }: { user: User | null }) {
|
||||
size={120}
|
||||
onUpload={(url) => {
|
||||
setAvatarUrl(url)
|
||||
updateProfile({ fullname, username, website, avatar_url: url })
|
||||
updateProfile({ fullname, website, avatar_url: url })
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-foreground/70">
|
||||
@@ -138,8 +103,8 @@ export default function AccountForm({ user }: { user: User | null }) {
|
||||
id="username"
|
||||
type="text"
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
disabled
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground/70 ring-1 ring-inset ring-[var(--border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +122,7 @@ export default function AccountForm({ user }: { user: User | null }) {
|
||||
<div className="sm:col-span-2 flex flex-col justify-center items-center gap-4 mt-4 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
className="shine-wrap btn-premium h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
|
||||
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
|
||||
onClick={() => updateProfile({ fullname, website, avatar_url })}
|
||||
disabled={loading || !hasChanges}
|
||||
>
|
||||
<span>{loading ? 'Saving...' : 'Update profile'}</span>
|
||||
|
||||
238
src/components/Account/AccountSetupForm.tsx
Normal file
238
src/components/Account/AccountSetupForm.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createClient } from '@/utils/supabase/client'
|
||||
import { type User } from '@supabase/supabase-js'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { FiCheck, FiX, FiCopy } from 'react-icons/fi'
|
||||
|
||||
type UsernameState =
|
||||
| { status: 'idle'; message: string }
|
||||
| { status: 'invalid'; message: string }
|
||||
| { status: 'checking'; message: string }
|
||||
| { status: 'unavailable'; message: string }
|
||||
| { status: 'available'; message: string }
|
||||
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
'admin', 'support', 'help', 'root', 'system', 'null', 'undefined', 'me', 'you', 'owner'
|
||||
])
|
||||
|
||||
function sanitizeUsername(input: string): string {
|
||||
const lowered = input.toLowerCase()
|
||||
// Keep only a-z, 0-9 and underscore
|
||||
return lowered.replace(/[^a-z0-9_]/g, '')
|
||||
}
|
||||
|
||||
function validateUsernameLocal(username: string): string | null {
|
||||
// Sorted by priority for good user experience
|
||||
if (username.length > 20) return 'Max 20 characters'
|
||||
if (/^_/.test(username)) return 'Cannot start or end with underscore'
|
||||
if (/__/.test(username)) return 'Avoid consecutive underscores'
|
||||
if (username.length < 3) return 'At least 3 characters'
|
||||
if (!/^[a-z0-9_]+$/.test(username)) return 'Use letters, numbers, and underscores only'
|
||||
if (/_$/.test(username)) return 'Cannot start or end with underscore'
|
||||
if (RESERVED_USERNAMES.has(username)) return 'That username is taken'
|
||||
return null
|
||||
}
|
||||
|
||||
export default function AccountSetupForm({ user }: { user: User }) {
|
||||
const supabase = createClient()
|
||||
const router = useRouter()
|
||||
|
||||
const [username, _setUsername] = useState('')
|
||||
const [usernameState, setUsernameState] = useState<UsernameState>({ status: 'idle', message: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const localError = validateUsernameLocal(username)
|
||||
|
||||
if (username.length === 0) {
|
||||
setUsernameState({ status: 'idle', message: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (localError) {
|
||||
setUsernameState({ status: 'invalid', message: localError })
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setUsernameState({ status: 'checking', message: 'Checking availability…' })
|
||||
|
||||
const handle = setTimeout(async () => {
|
||||
// Remote availability check (debounced)
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('username', username)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (!active) return
|
||||
|
||||
if (error) {
|
||||
setUsernameState({ status: 'invalid', message: 'Could not verify availability. Try again.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.id) {
|
||||
setUsernameState({ status: 'unavailable', message: 'That username is taken' })
|
||||
} else {
|
||||
setUsernameState({ status: 'available', message: 'Username is available' })
|
||||
}
|
||||
}, 450)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}, [username, supabase])
|
||||
|
||||
const isUnavailable = usernameState.status === 'unavailable'
|
||||
const isError = isUnavailable || usernameState.status === 'invalid'
|
||||
const isAvailable = usernameState.status === 'available'
|
||||
const canSubmit = isAvailable && !submitting
|
||||
|
||||
const setUsername = (value: string) => {
|
||||
_setUsername(sanitizeUsername(value))
|
||||
setCopied(false)
|
||||
}
|
||||
|
||||
const profileUrl = `${process.env.NEXT_PUBLIC_SITE_DOMAIN}/@${(isAvailable && username && username.length > 0) ? username : 'yourname'}`
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(profileUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const { error } = await supabase.from('profiles').upsert({
|
||||
id: user.id,
|
||||
username,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Refresh SSR boundary so the page re-renders into the update form state
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setUsernameState({ status: 'invalid', message: 'There was an error saving. Please try again.' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stateColor =
|
||||
usernameState.status === 'available' ? 'text-emerald-600 dark:text-emerald-400' :
|
||||
usernameState.status === 'unavailable' || usernameState.status === 'invalid' ? 'text-red-600 dark:text-red-400' :
|
||||
'text-foreground/70'
|
||||
const bgColor = isError
|
||||
? 'bg-rose-500/5 dark:bg-rose-500/5'
|
||||
: isAvailable
|
||||
? 'bg-emerald-600/5 dark:bg-emerald-500/5'
|
||||
: 'bg-[var(--surface-2)]'
|
||||
const ringColor = isError
|
||||
? 'ring-rose-500/60'
|
||||
: isAvailable
|
||||
? 'ring-emerald-600/70 dark:ring-emerald-500/60'
|
||||
: 'ring-[var(--border)]'
|
||||
const focusRing = isError
|
||||
? 'focus:ring-rose-500/70'
|
||||
: isAvailable
|
||||
? 'focus:ring-emerald-600/80 dark:focus:ring-emerald-500/70'
|
||||
: 'focus:ring-[var(--ring)]'
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="username" className="text-sm text-foreground/80">Choose a username<span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-foreground/60">@</span>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="yourname"
|
||||
className={`h-11 w-full rounded-md ${bgColor} pl-8 pr-10 text-sm ring-1 ring-inset ${ringColor} focus:outline-none focus:ring-2 ${focusRing}`}
|
||||
aria-describedby="username-help username-status"
|
||||
aria-invalid={usernameState.status === 'invalid' || usernameState.status === 'unavailable'}
|
||||
/>
|
||||
{isAvailable && (
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-emerald-600 dark:text-emerald-400" aria-hidden="true">
|
||||
<FiCheck />
|
||||
</span>
|
||||
)}
|
||||
{isUnavailable && (
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-rose-600 dark:text-rose-400" aria-hidden="true">
|
||||
<FiX />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div id="username-help" className="text-[13px] text-foreground/60">
|
||||
3–20 chars. Letters, numbers, underscores.
|
||||
</div>
|
||||
{username.length > 0 && (
|
||||
<div id="username-status" className={`text-[13px] ${stateColor}`}>
|
||||
{usernameState.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-1 card px-3 py-4 text-sm text-foreground/60 min-h-17 flex items-center overflow-hidden ${isAvailable ? '' : 'opacity-50 cursor-not-allowed'}`} aria-live="polite">
|
||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
||||
<p className="px-2">After you have uploaded your first hack to Hackdex, others can find your profile at:</p>
|
||||
<div className="flex w-full min-w-0 items-center h-10">
|
||||
<div className="flex-1 min-w-0 h-full flex items-center overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-color-muted pl-2 text-foreground/70 border-l border-y border-[var(--border)] rounded-l-md">
|
||||
<span className={`inline-block whitespace-nowrap text-foreground/80 ${isAvailable ? 'select-all' : 'select-none opacity-70'}`}>{profileUrl}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={isAvailable ? handleCopy : undefined}
|
||||
disabled={!isAvailable}
|
||||
className={`relative inline-flex items-center justify-center rounded-r-md px-2 h-full border border-[var(--border)] ${isAvailable ? 'text-foreground/70 hover:border-foreground/50 hover:bg-foreground/10' : 'opacity-50 text-foreground/50'}`}
|
||||
aria-label={isAvailable ? (copied ? 'Copied' : 'Copy profile URL') : 'Copy disabled until username is available'}
|
||||
aria-disabled={!isAvailable}
|
||||
>
|
||||
{isAvailable && copied ? <FiCheck /> : <FiCopy />}
|
||||
<span className={`pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-[var(--surface-2)] px-2 py-0.5 text-[11px] text-foreground ring-1 ring-inset ring-[var(--border)] shadow-sm transition-all duration-150 ${copied ? 'opacity-100 -translate-y-1' : 'opacity-0 translate-y-0'}`}>
|
||||
Copied
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="shine-wrap btn-premium h-14 w-full sm:h-11 sm:w-auto min-w-56 text-sm font-semibold disabled:cursor-not-allowed dark:disabled:opacity-70 disabled:[box-shadow:0_0_0_1px_var(--border)]"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
<span>{submitting ? 'Creating…' : 'Create my account'}</span>
|
||||
</button>
|
||||
<form action="/auth/signout" method="post">
|
||||
<button className="text-[13px] text-foreground/60 hover:text-foreground/80 underline-offset-4 hover:underline px-0 h-auto" type="submit">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user