1+ "use client" ;
2+
3+ import { useState , useEffect } from "react" ;
4+ import { Button } from "@codevs/ui/button" ;
5+ import { Input } from "@codevs/ui/input" ;
6+ import { Label } from "@codevs/ui/label" ;
7+ import { Copy , Check , Info , CheckCircle2 , XCircle , Loader2 } from "lucide-react" ;
8+ import {
9+ Tooltip ,
10+ TooltipContent ,
11+ TooltipProvider ,
12+ TooltipTrigger ,
13+ } from "@codevs/ui/tooltip" ;
14+ import { checkUsernameAvailability , updateUsername , getUsernameData } from "../action" ;
15+ import { useToast } from "@codevs/ui/use-toast" ;
16+
17+ interface AccountSettingsUsernameProps {
18+ userId : string ;
19+ }
20+
21+ export default function AccountSettingsUsername ( { userId } : AccountSettingsUsernameProps ) {
22+ const [ username , setUsername ] = useState ( "" ) ;
23+ const [ currentUsername , setCurrentUsername ] = useState ( "" ) ;
24+ const [ copied , setCopied ] = useState ( false ) ;
25+ const [ checking , setChecking ] = useState ( false ) ;
26+ const [ updating , setUpdating ] = useState ( false ) ;
27+ const [ availabilityStatus , setAvailabilityStatus ] = useState < {
28+ available : boolean | null ;
29+ message : string ;
30+ } > ( { available : null , message : "" } ) ;
31+ const [ cooldownDays , setCooldownDays ] = useState < number | null > ( null ) ;
32+
33+ const { toast } = useToast ( ) ;
34+
35+ // Fetch current username data on mount
36+ useEffect ( ( ) => {
37+ const fetchUserData = async ( ) => {
38+ const result = await getUsernameData ( userId ) ;
39+ if ( result . success && result . data ) {
40+ setCurrentUsername ( result . data . username || "" ) ;
41+
42+ // Calculate cooldown
43+ if ( result . data . username_updated_at ) {
44+ const lastUpdate = new Date ( result . data . username_updated_at ) ;
45+ const now = new Date ( ) ;
46+ const daysSinceUpdate = Math . floor ( ( now . getTime ( ) - lastUpdate . getTime ( ) ) / ( 1000 * 60 * 60 * 24 ) ) ;
47+ const remaining = Math . max ( 0 , 30 - daysSinceUpdate ) ;
48+ setCooldownDays ( remaining ) ;
49+ } else {
50+ setCooldownDays ( 0 ) ;
51+ }
52+ }
53+ } ;
54+ fetchUserData ( ) ;
55+ } , [ userId ] ) ;
56+
57+ // Debounced username availability check
58+ useEffect ( ( ) => {
59+ if ( ! username || username . length < 8 ) {
60+ setAvailabilityStatus ( { available : null , message : "" } ) ;
61+ return ;
62+ }
63+
64+ // Don't check if it's the same as current username
65+ if ( username . toLowerCase ( ) === currentUsername . toLowerCase ( ) ) {
66+ setAvailabilityStatus ( {
67+ available : null ,
68+ message : "This is your current username"
69+ } ) ;
70+ return ;
71+ }
72+
73+ const timeoutId = setTimeout ( async ( ) => {
74+ setChecking ( true ) ;
75+ const result = await checkUsernameAvailability ( username , userId ) ;
76+ setChecking ( false ) ;
77+
78+ if ( result . available ) {
79+ setAvailabilityStatus ( {
80+ available : true ,
81+ message : "Username is available" ,
82+ } ) ;
83+ } else {
84+ setAvailabilityStatus ( {
85+ available : false ,
86+ message : result . error || "Username is not available" ,
87+ } ) ;
88+ }
89+ } , 500 ) ; // 500ms debounce
90+
91+ return ( ) => clearTimeout ( timeoutId ) ;
92+ } , [ username , userId , currentUsername ] ) ;
93+
94+ const handleChangeUsername = async ( ) => {
95+ if ( ! username || username . length < 8 ) return ;
96+
97+ if ( cooldownDays && cooldownDays > 0 ) {
98+ toast ( {
99+ title : "Cannot update username" ,
100+ description : `You can change your username again in ${ cooldownDays } day${ cooldownDays !== 1 ? 's' : '' } ` ,
101+ variant : "destructive" ,
102+ } ) ;
103+ return ;
104+ }
105+
106+ setUpdating ( true ) ;
107+ const result = await updateUsername ( userId , username ) ;
108+ setUpdating ( false ) ;
109+
110+ if ( result . success ) {
111+ toast ( {
112+ title : "Success" ,
113+ description : "Username updated successfully" ,
114+ } ) ;
115+ setCurrentUsername ( username ) ;
116+ setUsername ( "" ) ;
117+ setCooldownDays ( 30 ) ;
118+ } else {
119+ toast ( {
120+ title : "Error" ,
121+ description : result . error || "Failed to update username" ,
122+ variant : "destructive" ,
123+ } ) ;
124+ }
125+ } ;
126+
127+ const copyAccountId = ( ) => {
128+ navigator . clipboard . writeText ( userId ) ;
129+ setCopied ( true ) ;
130+ setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
131+ } ;
132+
133+ // Mask the account ID showing only last 4 characters
134+ const maskedAccountId = userId . replace ( / .(? = .{ 4 } ) / g, "x" ) ;
135+
136+ const isUsernameValid = username . length >= 8 && availabilityStatus . available === true ;
137+ const canUpdate = isUsernameValid && ( ! cooldownDays || cooldownDays === 0 ) ;
138+
139+ return (
140+ < div className = "space-y-7" >
141+ < div className = "flex items-center gap-2" >
142+ < TooltipProvider >
143+ < Tooltip >
144+ < TooltipTrigger asChild >
145+ < Info className = "h-6 w-6 text-muted-foreground cursor-help" />
146+ </ TooltipTrigger >
147+ < h3 className = "text-2xl font-semibold" > Account Identity</ h3 >
148+ < TooltipContent className = "max-w-xs" >
149+ < p className = "text-base" >
150+ Your username is your unique identifier. The username must be at least 8 characters.
151+ </ p >
152+ </ TooltipContent >
153+ </ Tooltip >
154+ </ TooltipProvider >
155+ </ div >
156+
157+ < div className = "space-y-4" >
158+ { /* Current Username Display */ }
159+
160+ < div className = "bg-gray-800/50 rounded-md" >
161+ < p className = "text-lg text-foreground" > Current username</ p >
162+ < p className = "text-base font-medium text-muted-foreground" > { currentUsername || "No username set." } </ p >
163+ </ div >
164+
165+
166+ { /* Cooldown Warning */ }
167+ { cooldownDays !== null && cooldownDays > 0 && (
168+ < div className = "p-3 bg-yellow-900/20 border border-yellow-700/50 rounded-md" >
169+ < p className = "text-sm text-yellow-200" >
170+ ⏳ You can change your username again in { cooldownDays } day{ cooldownDays !== 1 ? 's' : '' }
171+ </ p >
172+ </ div >
173+ ) }
174+
175+ { /* Username Field */ }
176+ < div className = "space-y-2" >
177+ < Label htmlFor = "username" className = "text-lg font-medium" >
178+ New Username
179+ </ Label >
180+ < div className = "flex flex-col sm:flex-row gap-2" >
181+ < div className = "flex-1 relative" >
182+ < Input
183+ id = "username"
184+ type = "text"
185+ value = { username }
186+ onChange = { ( e ) => setUsername ( e . target . value . toLowerCase ( ) ) }
187+ placeholder = "Enter new username"
188+ className = "flex-1 text-base pr-10"
189+ minLength = { 8 }
190+ maxLength = { 30 }
191+ disabled = { cooldownDays !== null && cooldownDays > 0 }
192+ />
193+ { /* Availability Indicator */ }
194+ < div className = "absolute right-3 top-1/2 -translate-y-1/2" >
195+ { checking && < Loader2 className = "h-4 w-4 animate-spin text-gray-400" /> }
196+ { ! checking && availabilityStatus . available === true && (
197+ < CheckCircle2 className = "h-4 w-4 text-green-500" />
198+ ) }
199+ { ! checking && availabilityStatus . available === false && (
200+ < XCircle className = "h-4 w-4 text-red-500" />
201+ ) }
202+ </ div >
203+ </ div >
204+ < Button
205+ onClick = { handleChangeUsername }
206+ disabled = { ! canUpdate || updating }
207+ className = "shrink-0 bg-[#5B68C8] hover:bg-[#4A56B7] disabled:bg-gray-600 disabled:opacity-50"
208+ >
209+ { updating ? (
210+ < >
211+ < Loader2 className = "h-4 w-4 mr-2 animate-spin" />
212+ Updating...
213+ </ >
214+ ) : (
215+ "Change username"
216+ ) }
217+ </ Button >
218+ </ div >
219+
220+ { /* Status Message */ }
221+ { availabilityStatus . message && (
222+ < p className = { `text-sm ${
223+ availabilityStatus . available === true
224+ ? "text-green-400"
225+ : availabilityStatus . available === false
226+ ? "text-red-400"
227+ : "text-gray-400"
228+ } `} >
229+ { availabilityStatus . message }
230+ </ p >
231+ ) }
232+
233+ < p className = "text-sm text-gray-400" >
234+ 8-30 characters. Letters, numbers, periods, and underscores only.
235+ </ p >
236+ </ div >
237+
238+ { /* Account ID Field */ }
239+ < div className = "space-y-2" >
240+ < Label htmlFor = "account-id" className = "text-lg font-medium" >
241+ Account ID
242+ </ Label >
243+ < div className = "flex gap-2 items-center" >
244+ < Input
245+ id = "account-id"
246+ type = "text"
247+ value = { maskedAccountId }
248+ readOnly
249+ disabled
250+ className = "flex-1 font-mono text-base"
251+ />
252+ < Button
253+ onClick = { copyAccountId }
254+ variant = "ghost"
255+ size = "icon"
256+ className = "shrink-0"
257+ type = "button"
258+ >
259+ { copied ? (
260+ < Check className = "h-4 w-4 text-green-500" />
261+ ) : (
262+ < Copy className = "h-4 w-4" />
263+ ) }
264+ </ Button >
265+ </ div >
266+ < p className = "text-sm text-gray-400" >
267+ Your unique Codevs account identifier
268+ </ p >
269+ </ div >
270+ </ div >
271+ </ div >
272+ ) ;
273+ }
0 commit comments