Skip to content

Commit c972281

Browse files
committed
username update feature in profile settings
1 parent 656240c commit c972281

5 files changed

Lines changed: 435 additions & 12 deletions

File tree

‎apps/codebility/app/home/account-settings/_components/AccountSettingsChangePassword.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ export default function AccountSettingsChangePassword() {
7373
const isButtonDisabled = isSubmitting || !isFormValid || !isDirty;
7474

7575
return (
76-
<div className="space-y-2">
77-
<h3 className="text-base font-semibold">Change Password</h3>
76+
<div className="space-y-4">
77+
<h3 className="text-xl font-semibold">Change Password</h3>
7878

7979
<Form {...form}>
8080
<form
81-
className="space-y-2"
81+
className="space-y-3"
8282
noValidate
8383
onSubmit={form.handleSubmit(onSubmit)}
8484
>
@@ -92,7 +92,7 @@ export default function AccountSettingsChangePassword() {
9292
type="password"
9393
placeholder="New Password"
9494
label="New Password"
95-
parentClassName="flex gap-1 flex-col"
95+
parentClassName="flex gap-1 flex-col text-lg"
9696
variant="lightgray"
9797
className="text-sm"
9898
{...field}
@@ -113,7 +113,7 @@ export default function AccountSettingsChangePassword() {
113113
type="password"
114114
placeholder="Confirm Password"
115115
label="Confirm Password"
116-
parentClassName="flex gap-1 flex-col"
116+
parentClassName="flex gap-1 flex-col text-lg"
117117
variant="lightgray"
118118
className="text-sm"
119119
{...field}

‎apps/codebility/app/home/account-settings/_components/AccountSettingsHeader.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ export default function AccountSettingsHeader({
1414
}) {
1515
return (
1616
<>
17-
<CardHeader className="px-0 py-2">
18-
<CardTitle className="flex items-center text-lg">
17+
<CardHeader className="px-0">
18+
<CardTitle className="flex items-center text-2xl">
1919
<Lock className="mr-2 h-4 w-4" /> Account Security
2020
</CardTitle>
2121
<CardDescription className="text-xs">
2222
Manage your account credentials and security settings.
2323
</CardDescription>
2424
</CardHeader>
25-
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
25+
<div className="flex flex-col gap-2 sm:flex-row sm:items-end text-xl">
2626
<Input
2727
readOnly
2828
label="Email Address"
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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

Comments
 (0)