ai-chatbot/apps/editor/features/community/components/username-onboarding-dialog.tsx
Aymeric Rabot 62dde6ef5a feat: add Google OAuth, user profiles, community hub improvements
- Add Google OAuth sign-in alongside magic link authentication
- Username onboarding: require username selection after first sign-in
- Public profile pages at /u/[username] with social links (GitHub, X)
- Settings page for managing username and social links
- Viewer header: unified floating card with project name, @username, breadcrumb
- Guest CTA on viewer page prompting sign-in
- Show owner avatar + username on community project cards
- Redesigned project cards (larger preview, avatar + stats row below)
- Consistent navbar across hub and profile pages with GitHub link
- Footer with links to GitHub repo and npm packages
- Borderless design with natural shadows throughout
- Apple corner smoothing (squircle) progressive enhancement
- cursor:pointer globally on buttons and links
- DB migrations: username, github_url, x_url columns on auth_users
- Root-level db:generate/migrate/push/studio scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-19 01:04:37 -05:00

147 lines
5.1 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
import { updateUsername, checkUsernameAvailability } from '../lib/auth/actions'
interface UsernameOnboardingDialogProps {
open: boolean
onComplete: () => void
}
export function UsernameOnboardingDialog({ open, onComplete }: UsernameOnboardingDialogProps) {
const [username, setUsername] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [availability, setAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>(
'idle',
)
const validate = (value: string): string | null => {
if (value.length < 3) return 'Must be at least 3 characters'
if (value.length > 30) return 'Must be at most 30 characters'
if (!/^[a-zA-Z0-9_-]+$/.test(value))
return 'Only letters, numbers, hyphens, and underscores'
return null
}
const checkAvailability = useCallback(async (value: string) => {
const validationError = validate(value)
if (validationError) {
setAvailability('idle')
return
}
setAvailability('checking')
const result = await checkUsernameAvailability(value)
setAvailability(result.available ? 'available' : 'taken')
}, [])
useEffect(() => {
if (!username.trim()) {
setAvailability('idle')
return
}
const timer = setTimeout(() => checkAvailability(username.trim()), 300)
return () => clearTimeout(timer)
}, [username, checkAvailability])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = username.trim()
const validationError = validate(trimmed)
if (validationError) {
setError(validationError)
return
}
setError(null)
setIsSaving(true)
const result = await updateUsername(trimmed)
if (result.success) {
onComplete()
} else {
setError(result.error ?? 'Failed to set username')
}
setIsSaving(false)
}
const validationError = username.trim() ? validate(username.trim()) : null
const canSubmit = !isSaving && !validationError && availability === 'available'
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-[420px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Choose your username</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
Pick a public username for the community hub. This will be visible on projects you share.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
@
</span>
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="your-username"
className="w-full rounded-md border border-input bg-background pl-7 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSaving}
autoFocus
minLength={3}
maxLength={30}
/>
</div>
{/* Status indicators */}
{username.trim() && !validationError && (
<div className="text-xs">
{availability === 'checking' && (
<span className="text-muted-foreground">Checking availability...</span>
)}
{availability === 'available' && (
<span className="text-green-600 dark:text-green-400">Username is available</span>
)}
{availability === 'taken' && (
<span className="text-destructive">Username is already taken</span>
)}
</div>
)}
{validationError && (
<p className="text-destructive text-xs">{validationError}</p>
)}
{!username.trim() && (
<p className="text-muted-foreground text-xs">
3-30 characters. Letters, numbers, hyphens, and underscores only.
</p>
)}
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? 'Setting username...' : 'Continue'}
</button>
</form>
</DialogContent>
</Dialog>
)
}