mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
feat: Implement username change functionality
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -57,7 +57,7 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
@@ -7172,9 +7172,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
|
||||
"integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
|
||||
17
src/hooks/useDebounce.ts
Normal file
17
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
80
src/hooks/useUsernameValidation.ts
Normal file
80
src/hooks/useUsernameValidation.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { usernameSchema } from '@/lib/validation';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
export type UsernameValidationState = {
|
||||
isValid: boolean;
|
||||
isAvailable: boolean | null;
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function useUsernameValidation(username: string, currentUsername?: string) {
|
||||
const [state, setState] = useState<UsernameValidationState>({
|
||||
isValid: false,
|
||||
isAvailable: null,
|
||||
isChecking: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const debouncedUsername = useDebounce(username, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedUsername || debouncedUsername === currentUsername) {
|
||||
setState({
|
||||
isValid: debouncedUsername === currentUsername,
|
||||
isAvailable: null,
|
||||
isChecking: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate format first
|
||||
const validation = usernameSchema.safeParse(debouncedUsername);
|
||||
if (!validation.success) {
|
||||
setState({
|
||||
isValid: false,
|
||||
isAvailable: null,
|
||||
isChecking: false,
|
||||
error: validation.error.issues[0].message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability
|
||||
setState(prev => ({ ...prev, isChecking: true, error: null }));
|
||||
|
||||
checkUsernameAvailability(validation.data);
|
||||
}, [debouncedUsername, currentUsername]);
|
||||
|
||||
const checkUsernameAvailability = async (normalizedUsername: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('username', normalizedUsername)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const isAvailable = !data;
|
||||
setState({
|
||||
isValid: isAvailable,
|
||||
isAvailable,
|
||||
isChecking: false,
|
||||
error: isAvailable ? null : 'Username is already taken',
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
isValid: false,
|
||||
isAvailable: null,
|
||||
isChecking: false,
|
||||
error: 'Error checking username availability',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
17
src/lib/validation.ts
Normal file
17
src/lib/validation.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(30, 'Username must be less than 30 characters')
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens')
|
||||
.regex(/^[a-zA-Z0-9]/, 'Username must start with a letter or number')
|
||||
.transform(val => val.toLowerCase());
|
||||
|
||||
export const profileEditSchema = z.object({
|
||||
username: usernameSchema,
|
||||
display_name: z.string().max(100, 'Display name must be less than 100 characters').optional(),
|
||||
bio: z.string().max(500, 'Bio must be less than 500 characters').optional(),
|
||||
});
|
||||
|
||||
export type ProfileEditForm = z.infer<typeof profileEditSchema>;
|
||||
@@ -10,7 +10,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
||||
import {
|
||||
User,
|
||||
MapPin,
|
||||
@@ -22,12 +24,16 @@ import {
|
||||
Edit3,
|
||||
Save,
|
||||
X,
|
||||
ArrowLeft
|
||||
ArrowLeft,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Profile as ProfileType } from '@/types/database';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { PhotoUpload } from '@/components/upload/PhotoUpload';
|
||||
import { profileEditSchema } from '@/lib/validation';
|
||||
|
||||
export default function Profile() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
@@ -39,11 +45,17 @@ export default function Profile() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
username: '',
|
||||
display_name: '',
|
||||
bio: '',
|
||||
});
|
||||
const [showUsernameDialog, setShowUsernameDialog] = useState(false);
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>('');
|
||||
const [avatarImageId, setAvatarImageId] = useState<string>('');
|
||||
|
||||
// Username validation
|
||||
const usernameValidation = useUsernameValidation(editForm.username, profile?.username);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentUser();
|
||||
@@ -72,6 +84,7 @@ export default function Profile() {
|
||||
if (data) {
|
||||
setProfile(data as ProfileType);
|
||||
setEditForm({
|
||||
username: data.username || '',
|
||||
display_name: data.display_name || '',
|
||||
bio: data.bio || '',
|
||||
});
|
||||
@@ -110,6 +123,7 @@ export default function Profile() {
|
||||
if (data) {
|
||||
setProfile(data as ProfileType);
|
||||
setEditForm({
|
||||
username: data.username || '',
|
||||
display_name: data.display_name || '',
|
||||
bio: data.bio || '',
|
||||
});
|
||||
@@ -128,35 +142,80 @@ export default function Profile() {
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const result = profileEditSchema.safeParse(editForm);
|
||||
if (!result.success) {
|
||||
const errors: Record<string, string> = {};
|
||||
result.error.issues.forEach((issue) => {
|
||||
if (issue.path[0]) {
|
||||
errors[issue.path[0] as string] = issue.message;
|
||||
}
|
||||
});
|
||||
setFormErrors(errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!usernameValidation.isValid && editForm.username !== profile?.username) {
|
||||
setFormErrors({ username: usernameValidation.error || 'Invalid username' });
|
||||
return false;
|
||||
}
|
||||
|
||||
setFormErrors({});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!profile || !currentUser) return;
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
const usernameChanged = editForm.username !== profile.username;
|
||||
|
||||
if (usernameChanged && !showUsernameDialog) {
|
||||
setShowUsernameDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData: any = {
|
||||
display_name: editForm.display_name,
|
||||
bio: editForm.bio,
|
||||
avatar_url: avatarUrl,
|
||||
avatar_image_id: avatarImageId
|
||||
};
|
||||
|
||||
if (usernameChanged) {
|
||||
updateData.username = editForm.username;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
display_name: editForm.display_name,
|
||||
bio: editForm.bio,
|
||||
avatar_url: avatarUrl,
|
||||
avatar_image_id: avatarImageId
|
||||
})
|
||||
.update(updateData)
|
||||
.eq('user_id', currentUser.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setProfile(prev => prev ? {
|
||||
...prev,
|
||||
display_name: editForm.display_name,
|
||||
bio: editForm.bio,
|
||||
avatar_url: avatarUrl,
|
||||
avatar_image_id: avatarImageId
|
||||
...updateData
|
||||
} : null);
|
||||
|
||||
setEditing(false);
|
||||
toast({
|
||||
title: "Profile updated",
|
||||
description: "Your profile has been updated successfully.",
|
||||
});
|
||||
setShowUsernameDialog(false);
|
||||
|
||||
if (usernameChanged) {
|
||||
toast({
|
||||
title: "Profile updated",
|
||||
description: "Your username and profile URL have been updated successfully.",
|
||||
});
|
||||
// Navigate to new username URL
|
||||
navigate(`/profile/${editForm.username}`);
|
||||
} else {
|
||||
toast({
|
||||
title: "Profile updated",
|
||||
description: "Your profile has been updated successfully.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -166,6 +225,11 @@ export default function Profile() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmUsernameChange = () => {
|
||||
setShowUsernameDialog(false);
|
||||
handleSaveProfile();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
|
||||
if (!currentUser || !urls[0]) return;
|
||||
|
||||
@@ -298,6 +362,42 @@ export default function Profile() {
|
||||
<div className="flex-1">
|
||||
{editing && isOwnProfile ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="username"
|
||||
value={editForm.username}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="your_username"
|
||||
className={`pr-10 ${formErrors.username ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{usernameValidation.isChecking ? (
|
||||
<Loader2 className="w-4 h-4 text-muted-foreground animate-spin" />
|
||||
) : editForm.username === profile?.username ? (
|
||||
<Check className="w-4 h-4 text-muted-foreground" />
|
||||
) : usernameValidation.isValid ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : usernameValidation.error ? (
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{formErrors.username && (
|
||||
<p className="text-sm text-destructive mt-1">{formErrors.username}</p>
|
||||
)}
|
||||
{usernameValidation.error && editForm.username !== profile?.username && (
|
||||
<p className="text-sm text-destructive mt-1">{usernameValidation.error}</p>
|
||||
)}
|
||||
{usernameValidation.isValid && editForm.username !== profile?.username && (
|
||||
<p className="text-sm text-green-600 mt-1">Username is available!</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Your profile URL will be /profile/{editForm.username}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="display_name">Display Name</Label>
|
||||
<Input
|
||||
@@ -305,7 +405,11 @@ export default function Profile() {
|
||||
value={editForm.display_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, display_name: e.target.value }))}
|
||||
placeholder="Your display name"
|
||||
className={formErrors.display_name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{formErrors.display_name && (
|
||||
<p className="text-sm text-destructive mt-1">{formErrors.display_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -316,17 +420,33 @@ export default function Profile() {
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, bio: e.target.value }))}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={3}
|
||||
className={formErrors.bio ? 'border-destructive' : ''}
|
||||
/>
|
||||
{formErrors.bio && (
|
||||
<p className="text-sm text-destructive mt-1">{formErrors.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveProfile} size="sm">
|
||||
<Button
|
||||
onClick={handleSaveProfile}
|
||||
size="sm"
|
||||
disabled={usernameValidation.isChecking || (editForm.username !== profile?.username && !usernameValidation.isValid)}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditing(false)}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setFormErrors({});
|
||||
setEditForm({
|
||||
username: profile?.username || '',
|
||||
display_name: profile?.display_name || '',
|
||||
bio: profile?.bio || '',
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
@@ -493,6 +613,32 @@ export default function Profile() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
|
||||
{/* Username Change Confirmation Dialog */}
|
||||
<AlertDialog open={showUsernameDialog} onOpenChange={setShowUsernameDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Change Username?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Changing your username will also change your profile URL from{' '}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||
/profile/{profile?.username}
|
||||
</code>{' '}
|
||||
to{' '}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||
/profile/{editForm.username}
|
||||
</code>
|
||||
. This means any existing bookmarks or links to your profile will no longer work.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmUsernameChange}>
|
||||
Change Username
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user