🎨 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 {
|
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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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
|
||||||
|
|
|
||||||
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