🎨 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:
parent
887a9bc285
commit
b3240dbb3c
3 changed files with 311 additions and 14 deletions
|
|
@ -22,33 +22,69 @@ export default async function handler(req, res) {
|
|||
}
|
||||
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const { email, password, firstName, lastName, username, profileImage } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
// Validate required fields
|
||||
if (!email || !password || !firstName || !lastName || !username) {
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
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`
|
||||
SELECT id FROM users WHERE email = ${email}
|
||||
SELECT id FROM users WHERE email = ${email} OR username = ${username}
|
||||
`;
|
||||
|
||||
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
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
// Create user with all fields
|
||||
const result = await sql`
|
||||
INSERT INTO users (email, password, role)
|
||||
VALUES (${email}, ${hashedPassword}, ${'user'})
|
||||
RETURNING id, email, role, created_at
|
||||
INSERT INTO users (
|
||||
email,
|
||||
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];
|
||||
|
|
@ -110,9 +146,21 @@ export default async function handler(req, res) {
|
|||
{ 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({
|
||||
success: true,
|
||||
user,
|
||||
user: userResponse,
|
||||
token
|
||||
});
|
||||
|
||||
|
|
|
|||
194
pages/signup.js
194
pages/signup.js
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import AuthLayout from '../components/AuthLayout';
|
||||
|
|
@ -11,11 +11,34 @@ export default function Signup() {
|
|||
password: '',
|
||||
confirmPassword: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
lastName: '',
|
||||
username: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = 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) => {
|
||||
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 errors = {};
|
||||
|
||||
|
|
@ -42,6 +121,15 @@ export default function Signup() {
|
|||
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
|
||||
if (!formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
|
|
@ -89,7 +177,9 @@ export default function Signup() {
|
|||
email: formData.email,
|
||||
password: formData.password,
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Email Address
|
||||
|
|
|
|||
61
scripts/add-user-profile-columns.js
Normal file
61
scripts/add-user-profile-columns.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue