🎨 Enhanced Signup with Username & Profile Images

 New Signup Features:
- Added username field with validation (3+ chars, alphanumeric + underscore)
- Profile image upload with file validation (5MB max)
- DiceBear Adventurer Neutral API integration for random avatars
- Generate new random avatar button with dice emoji
- Initial random avatar generation on page load

🔧 Backend Updates:
- Updated registration API to handle all new fields
- Username uniqueness validation with specific error messages
- Profile image URL storage in database
- Enhanced user response with all profile data

🗄️ Database Migration:
- Added first_name, last_name, username, profile_image_url columns
- Unique constraint on username field
- Migration script with existing user updates
- Default values for existing accounts

🎯 User Experience:
- Real-time form validation with error states
- Loading states for image upload/generation
- File type and size validation
- Clean profile image preview with rounded borders
- Consistent styling with existing theme

Ready for enhanced user profiles! 🚀
This commit is contained in:
Randall Stillwell 2025-07-28 11:18:58 -05:00
parent 887a9bc285
commit b3240dbb3c
3 changed files with 311 additions and 14 deletions

View file

@ -22,33 +22,69 @@ export default async function handler(req, res) {
} }
try { try {
const { email, password } = req.body; const { email, password, firstName, lastName, username, profileImage } = req.body;
if (!email || !password) { // Validate required fields
return res.status(400).json({ error: 'Email and password are required' }); if (!email || !password || !firstName || !lastName || !username) {
return res.status(400).json({ error: 'All fields are required' });
} }
if (password.length < 6) { if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' }); return res.status(400).json({ error: 'Password must be at least 6 characters' });
} }
// Check if user already exists // Validate username
if (username.length < 3) {
return res.status(400).json({ error: 'Username must be at least 3 characters' });
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return res.status(400).json({ error: 'Username can only contain letters, numbers, and underscores' });
}
// Check if user already exists (email or username)
const existingUser = await sql` const existingUser = await sql`
SELECT id FROM users WHERE email = ${email} SELECT id FROM users WHERE email = ${email} OR username = ${username}
`; `;
if (existingUser.rows.length > 0) { if (existingUser.rows.length > 0) {
return res.status(409).json({ error: 'User already exists' }); // Check which field conflicts
const conflictUser = await sql`
SELECT email, username FROM users WHERE email = ${email} OR username = ${username}
`;
const conflict = conflictUser.rows[0];
if (conflict.email === email) {
return res.status(409).json({ error: 'Email already exists' });
} else {
return res.status(409).json({ error: 'Username already taken' });
}
} }
// Hash password // Hash password
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
// Create user // Create user with all fields
const result = await sql` const result = await sql`
INSERT INTO users (email, password, role) INSERT INTO users (
VALUES (${email}, ${hashedPassword}, ${'user'}) email,
RETURNING id, email, role, created_at password,
first_name,
last_name,
username,
profile_image_url,
role
)
VALUES (
${email},
${hashedPassword},
${firstName},
${lastName},
${username},
${profileImage || null},
${'user'}
)
RETURNING id, email, first_name, last_name, username, profile_image_url, role, created_at
`; `;
const user = result.rows[0]; const user = result.rows[0];
@ -110,9 +146,21 @@ export default async function handler(req, res) {
{ expiresIn: '24h' } { expiresIn: '24h' }
); );
// Return user data without password
const userResponse = {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
username: user.username,
profileImage: user.profile_image_url,
role: user.role,
createdAt: user.created_at
};
res.status(201).json({ res.status(201).json({
success: true, success: true,
user, user: userResponse,
token token
}); });

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import AuthLayout from '../components/AuthLayout'; import AuthLayout from '../components/AuthLayout';
@ -11,11 +11,34 @@ export default function Signup() {
password: '', password: '',
confirmPassword: '', confirmPassword: '',
firstName: '', firstName: '',
lastName: '' lastName: '',
username: ''
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [validationErrors, setValidationErrors] = useState({}); const [validationErrors, setValidationErrors] = useState({});
const [profileImage, setProfileImage] = useState('');
const [uploadedImage, setUploadedImage] = useState(null);
const [imageLoading, setImageLoading] = useState(false);
// Generate initial random avatar
useEffect(() => {
generateRandomAvatar();
}, []);
const generateRandomAvatar = async () => {
setImageLoading(true);
try {
const seed = Math.random().toString(36).substring(7);
const avatarUrl = `https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=${seed}&size=200&backgroundColor=f3f4f6`;
setProfileImage(avatarUrl);
setUploadedImage(null); // Clear any uploaded image
} catch (error) {
console.error('Error generating avatar:', error);
} finally {
setImageLoading(false);
}
};
const handleInputChange = (field, value) => { const handleInputChange = (field, value) => {
setFormData(prev => ({ setFormData(prev => ({
@ -32,6 +55,62 @@ export default function Signup() {
} }
}; };
const handleImageUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setValidationErrors(prev => ({
...prev,
image: 'Please select a valid image file'
}));
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setValidationErrors(prev => ({
...prev,
image: 'Image must be less than 5MB'
}));
return;
}
setImageLoading(true);
try {
// Create FormData for file upload
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/user/avatar', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
setUploadedImage(data.url);
setProfileImage(data.url);
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors.image;
return newErrors;
});
} else {
throw new Error('Failed to upload image');
}
} catch (error) {
console.error('Error uploading image:', error);
setValidationErrors(prev => ({
...prev,
image: 'Failed to upload image. Please try again.'
}));
} finally {
setImageLoading(false);
}
};
const validateForm = () => { const validateForm = () => {
const errors = {}; const errors = {};
@ -42,6 +121,15 @@ export default function Signup() {
errors.email = 'Please enter a valid email address'; errors.email = 'Please enter a valid email address';
} }
// Username validation
if (!formData.username) {
errors.username = 'Username is required';
} else if (formData.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
errors.username = 'Username can only contain letters, numbers, and underscores';
}
// Password validation // Password validation
if (!formData.password) { if (!formData.password) {
errors.password = 'Password is required'; errors.password = 'Password is required';
@ -89,7 +177,9 @@ export default function Signup() {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName lastName: formData.lastName,
username: formData.username,
profileImage: profileImage // Send the current profile image (either uploaded or generated)
}) })
}); });
@ -144,6 +234,80 @@ export default function Signup() {
</div> </div>
)} )}
{/* Profile Image Section */}
<div className="text-center">
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
Profile Image
</label>
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{imageLoading ? (
<div
className="w-24 h-24 rounded-full border-2 flex items-center justify-center backdrop-blur-sm"
style={{
borderColor: 'var(--border)',
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)'
}}
>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
) : (
<div
className="w-24 h-24 rounded-full border-2 overflow-hidden"
style={{ borderColor: 'var(--border)' }}
>
{profileImage && (
<img
src={profileImage}
alt="Profile"
className="w-full h-full object-cover"
/>
)}
</div>
)}
</div>
<div className="flex flex-col space-y-2">
<label
htmlFor="imageUpload"
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer transition-all duration-200 hover:shadow-md backdrop-blur-sm border border-opacity-20"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
Upload Image
</label>
<input
id="imageUpload"
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<button
type="button"
onClick={generateRandomAvatar}
disabled={imageLoading}
className="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 hover:shadow-md backdrop-blur-sm border border-opacity-20"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
{imageLoading ? 'Generating...' : '🎲 Random Avatar'}
</button>
</div>
{validationErrors.image && (
<p className="text-xs text-red-400">{validationErrors.image}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label htmlFor="firstName" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
@ -194,6 +358,30 @@ export default function Signup() {
</div> </div>
</div> </div>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Username
</label>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: validationErrors.username ? '#ef4444' : 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="johndoe123"
/>
{validationErrors.username && (
<p className="mt-1 text-xs text-red-400">{validationErrors.username}</p>
)}
</div>
<div> <div>
<label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Email Address Email Address

View file

@ -0,0 +1,61 @@
import { sql } from '@vercel/postgres';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config({ path: '.env.local' });
async function addUserProfileColumns() {
console.log('🔄 Adding user profile columns to users table...');
try {
// Add new columns to users table
await sql`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE,
ADD COLUMN IF NOT EXISTS profile_image_url TEXT
`;
console.log('✅ Successfully added user profile columns');
// Update existing users with default values
console.log('🔄 Updating existing users with default values...');
const existingUsers = await sql`SELECT id, email FROM users WHERE first_name IS NULL`;
for (const user of existingUsers.rows) {
// Generate default values from email
const emailPrefix = user.email.split('@')[0];
const defaultUsername = `${emailPrefix}_${user.id}`;
await sql`
UPDATE users
SET
first_name = 'User',
last_name = ${user.id.toString()},
username = ${defaultUsername}
WHERE id = ${user.id}
`;
console.log(`✅ Updated user ${user.email} with default values`);
}
console.log('✅ Migration completed successfully!');
} catch (error) {
console.error('❌ Error during migration:', error);
throw error;
}
}
// Run the migration
addUserProfileColumns()
.then(() => {
console.log('🎉 User profile columns migration completed!');
process.exit(0);
})
.catch((error) => {
console.error('💥 Migration failed:', error);
process.exit(1);
});