From 6b329f688719b086c658d7639f2a8ba2fce34bfc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 17:53:37 +0000 Subject: [PATCH] feat: Implement username change functionality --- package-lock.json | 8 +- package.json | 2 +- src/hooks/useDebounce.ts | 17 +++ src/hooks/useUsernameValidation.ts | 80 +++++++++++++ src/lib/validation.ts | 17 +++ src/pages/Profile.tsx | 180 ++++++++++++++++++++++++++--- 6 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/hooks/useUsernameValidation.ts create mode 100644 src/lib/validation.ts diff --git a/package-lock.json b/package-lock.json index 64df6ca1..d6c5602a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 6a7437b7..97778606 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..2605ca68 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/hooks/useUsernameValidation.ts b/src/hooks/useUsernameValidation.ts new file mode 100644 index 00000000..5a1f55f4 --- /dev/null +++ b/src/hooks/useUsernameValidation.ts @@ -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({ + 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; +} \ No newline at end of file diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 00000000..0fb16bc3 --- /dev/null +++ b/src/lib/validation.ts @@ -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; \ No newline at end of file diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index bc9ecb77..21212e84 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -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(null); const [editForm, setEditForm] = useState({ + username: '', display_name: '', bio: '', }); + const [showUsernameDialog, setShowUsernameDialog] = useState(false); + const [formErrors, setFormErrors] = useState>({}); const [avatarUrl, setAvatarUrl] = useState(''); const [avatarImageId, setAvatarImageId] = useState(''); + + // 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 = {}; + 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() {
{editing && isOwnProfile ? (
+
+ +
+ setEditForm(prev => ({ ...prev, username: e.target.value }))} + placeholder="your_username" + className={`pr-10 ${formErrors.username ? 'border-destructive' : ''}`} + /> +
+ {usernameValidation.isChecking ? ( + + ) : editForm.username === profile?.username ? ( + + ) : usernameValidation.isValid ? ( + + ) : usernameValidation.error ? ( + + ) : null} +
+
+ {formErrors.username && ( +

{formErrors.username}

+ )} + {usernameValidation.error && editForm.username !== profile?.username && ( +

{usernameValidation.error}

+ )} + {usernameValidation.isValid && editForm.username !== profile?.username && ( +

Username is available!

+ )} +

+ Your profile URL will be /profile/{editForm.username} +

+
+
setEditForm(prev => ({ ...prev, display_name: e.target.value }))} placeholder="Your display name" + className={formErrors.display_name ? 'border-destructive' : ''} /> + {formErrors.display_name && ( +

{formErrors.display_name}

+ )}
@@ -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 && ( +

{formErrors.bio}

+ )}
-
); } \ No newline at end of file