feat: add country and city fields to user profile
- Add country and city fields to User model - Create database migration for location fields - Add validation for country and city (max 100 characters) - Create countries.js with complete list of 195 countries - Add country dropdown select and city text input to profile page - Include country and city in GET /api/users/me response - Update profile form to support location data Users can now select their country from a dropdown list of all countries and enter their city name.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "city" VARCHAR(100),
|
||||||
|
ADD COLUMN "country" VARCHAR(100);
|
||||||
@@ -29,6 +29,10 @@ model User {
|
|||||||
facebookUrl String? @map("facebook_url") @db.VarChar(255)
|
facebookUrl String? @map("facebook_url") @db.VarChar(255)
|
||||||
tiktokUrl String? @map("tiktok_url") @db.VarChar(255)
|
tiktokUrl String? @map("tiktok_url") @db.VarChar(255)
|
||||||
|
|
||||||
|
// Location
|
||||||
|
country String? @db.VarChar(100)
|
||||||
|
city String? @db.VarChar(100)
|
||||||
|
|
||||||
// Email Verification (Phase 1.5)
|
// Email Verification (Phase 1.5)
|
||||||
emailVerified Boolean @default(false) @map("email_verified")
|
emailVerified Boolean @default(false) @map("email_verified")
|
||||||
verificationToken String? @unique @map("verification_token") @db.VarChar(255)
|
verificationToken String? @unique @map("verification_token") @db.VarChar(255)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { sanitizeForEmail } = require('../utils/sanitize');
|
|||||||
async function updateProfile(req, res, next) {
|
async function updateProfile(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const { firstName, lastName, email, wsdcId, youtubeUrl, instagramUrl, facebookUrl, tiktokUrl } = req.body;
|
const { firstName, lastName, email, wsdcId, youtubeUrl, instagramUrl, facebookUrl, tiktokUrl, country, city } = req.body;
|
||||||
|
|
||||||
// Build update data
|
// Build update data
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
@@ -21,6 +21,8 @@ async function updateProfile(req, res, next) {
|
|||||||
if (instagramUrl !== undefined) updateData.instagramUrl = instagramUrl || null;
|
if (instagramUrl !== undefined) updateData.instagramUrl = instagramUrl || null;
|
||||||
if (facebookUrl !== undefined) updateData.facebookUrl = facebookUrl || null;
|
if (facebookUrl !== undefined) updateData.facebookUrl = facebookUrl || null;
|
||||||
if (tiktokUrl !== undefined) updateData.tiktokUrl = tiktokUrl || null;
|
if (tiktokUrl !== undefined) updateData.tiktokUrl = tiktokUrl || null;
|
||||||
|
if (country !== undefined) updateData.country = country || null;
|
||||||
|
if (city !== undefined) updateData.city = city || null;
|
||||||
|
|
||||||
// Check if email is being changed
|
// Check if email is being changed
|
||||||
const currentUser = await prisma.user.findUnique({
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -168,6 +168,16 @@ const updateProfileValidation = [
|
|||||||
return value.includes('tiktok.com');
|
return value.includes('tiktok.com');
|
||||||
})
|
})
|
||||||
.withMessage('Must be a valid TikTok URL (tiktok.com)'),
|
.withMessage('Must be a valid TikTok URL (tiktok.com)'),
|
||||||
|
body('country')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage('Country must be less than 100 characters'),
|
||||||
|
body('city')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage('City must be less than 100 characters'),
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ router.get('/me', authenticate, async (req, res, next) => {
|
|||||||
instagramUrl: true,
|
instagramUrl: true,
|
||||||
facebookUrl: true,
|
facebookUrl: true,
|
||||||
tiktokUrl: true,
|
tiktokUrl: true,
|
||||||
|
country: true,
|
||||||
|
city: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
|||||||
200
frontend/src/data/countries.js
Normal file
200
frontend/src/data/countries.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// Complete list of countries (ISO 3166-1)
|
||||||
|
export const COUNTRIES = [
|
||||||
|
'Afghanistan',
|
||||||
|
'Albania',
|
||||||
|
'Algeria',
|
||||||
|
'Andorra',
|
||||||
|
'Angola',
|
||||||
|
'Antigua and Barbuda',
|
||||||
|
'Argentina',
|
||||||
|
'Armenia',
|
||||||
|
'Australia',
|
||||||
|
'Austria',
|
||||||
|
'Azerbaijan',
|
||||||
|
'Bahamas',
|
||||||
|
'Bahrain',
|
||||||
|
'Bangladesh',
|
||||||
|
'Barbados',
|
||||||
|
'Belarus',
|
||||||
|
'Belgium',
|
||||||
|
'Belize',
|
||||||
|
'Benin',
|
||||||
|
'Bhutan',
|
||||||
|
'Bolivia',
|
||||||
|
'Bosnia and Herzegovina',
|
||||||
|
'Botswana',
|
||||||
|
'Brazil',
|
||||||
|
'Brunei',
|
||||||
|
'Bulgaria',
|
||||||
|
'Burkina Faso',
|
||||||
|
'Burundi',
|
||||||
|
'Cabo Verde',
|
||||||
|
'Cambodia',
|
||||||
|
'Cameroon',
|
||||||
|
'Canada',
|
||||||
|
'Central African Republic',
|
||||||
|
'Chad',
|
||||||
|
'Chile',
|
||||||
|
'China',
|
||||||
|
'Colombia',
|
||||||
|
'Comoros',
|
||||||
|
'Congo',
|
||||||
|
'Costa Rica',
|
||||||
|
'Croatia',
|
||||||
|
'Cuba',
|
||||||
|
'Cyprus',
|
||||||
|
'Czech Republic',
|
||||||
|
'Denmark',
|
||||||
|
'Djibouti',
|
||||||
|
'Dominica',
|
||||||
|
'Dominican Republic',
|
||||||
|
'DR Congo',
|
||||||
|
'Ecuador',
|
||||||
|
'Egypt',
|
||||||
|
'El Salvador',
|
||||||
|
'Equatorial Guinea',
|
||||||
|
'Eritrea',
|
||||||
|
'Estonia',
|
||||||
|
'Eswatini',
|
||||||
|
'Ethiopia',
|
||||||
|
'Fiji',
|
||||||
|
'Finland',
|
||||||
|
'France',
|
||||||
|
'Gabon',
|
||||||
|
'Gambia',
|
||||||
|
'Georgia',
|
||||||
|
'Germany',
|
||||||
|
'Ghana',
|
||||||
|
'Greece',
|
||||||
|
'Grenada',
|
||||||
|
'Guatemala',
|
||||||
|
'Guinea',
|
||||||
|
'Guinea-Bissau',
|
||||||
|
'Guyana',
|
||||||
|
'Haiti',
|
||||||
|
'Honduras',
|
||||||
|
'Hungary',
|
||||||
|
'Iceland',
|
||||||
|
'India',
|
||||||
|
'Indonesia',
|
||||||
|
'Iran',
|
||||||
|
'Iraq',
|
||||||
|
'Ireland',
|
||||||
|
'Israel',
|
||||||
|
'Italy',
|
||||||
|
'Ivory Coast',
|
||||||
|
'Jamaica',
|
||||||
|
'Japan',
|
||||||
|
'Jordan',
|
||||||
|
'Kazakhstan',
|
||||||
|
'Kenya',
|
||||||
|
'Kiribati',
|
||||||
|
'Kosovo',
|
||||||
|
'Kuwait',
|
||||||
|
'Kyrgyzstan',
|
||||||
|
'Laos',
|
||||||
|
'Latvia',
|
||||||
|
'Lebanon',
|
||||||
|
'Lesotho',
|
||||||
|
'Liberia',
|
||||||
|
'Libya',
|
||||||
|
'Liechtenstein',
|
||||||
|
'Lithuania',
|
||||||
|
'Luxembourg',
|
||||||
|
'Madagascar',
|
||||||
|
'Malawi',
|
||||||
|
'Malaysia',
|
||||||
|
'Maldives',
|
||||||
|
'Mali',
|
||||||
|
'Malta',
|
||||||
|
'Marshall Islands',
|
||||||
|
'Mauritania',
|
||||||
|
'Mauritius',
|
||||||
|
'Mexico',
|
||||||
|
'Micronesia',
|
||||||
|
'Moldova',
|
||||||
|
'Monaco',
|
||||||
|
'Mongolia',
|
||||||
|
'Montenegro',
|
||||||
|
'Morocco',
|
||||||
|
'Mozambique',
|
||||||
|
'Myanmar',
|
||||||
|
'Namibia',
|
||||||
|
'Nauru',
|
||||||
|
'Nepal',
|
||||||
|
'Netherlands',
|
||||||
|
'New Zealand',
|
||||||
|
'Nicaragua',
|
||||||
|
'Niger',
|
||||||
|
'Nigeria',
|
||||||
|
'North Korea',
|
||||||
|
'North Macedonia',
|
||||||
|
'Norway',
|
||||||
|
'Oman',
|
||||||
|
'Pakistan',
|
||||||
|
'Palau',
|
||||||
|
'Palestine',
|
||||||
|
'Panama',
|
||||||
|
'Papua New Guinea',
|
||||||
|
'Paraguay',
|
||||||
|
'Peru',
|
||||||
|
'Philippines',
|
||||||
|
'Poland',
|
||||||
|
'Portugal',
|
||||||
|
'Qatar',
|
||||||
|
'Romania',
|
||||||
|
'Russia',
|
||||||
|
'Rwanda',
|
||||||
|
'Saint Kitts and Nevis',
|
||||||
|
'Saint Lucia',
|
||||||
|
'Saint Vincent and the Grenadines',
|
||||||
|
'Samoa',
|
||||||
|
'San Marino',
|
||||||
|
'Sao Tome and Principe',
|
||||||
|
'Saudi Arabia',
|
||||||
|
'Senegal',
|
||||||
|
'Serbia',
|
||||||
|
'Seychelles',
|
||||||
|
'Sierra Leone',
|
||||||
|
'Singapore',
|
||||||
|
'Slovakia',
|
||||||
|
'Slovenia',
|
||||||
|
'Solomon Islands',
|
||||||
|
'Somalia',
|
||||||
|
'South Africa',
|
||||||
|
'South Korea',
|
||||||
|
'South Sudan',
|
||||||
|
'Spain',
|
||||||
|
'Sri Lanka',
|
||||||
|
'Sudan',
|
||||||
|
'Suriname',
|
||||||
|
'Sweden',
|
||||||
|
'Switzerland',
|
||||||
|
'Syria',
|
||||||
|
'Taiwan',
|
||||||
|
'Tajikistan',
|
||||||
|
'Tanzania',
|
||||||
|
'Thailand',
|
||||||
|
'Timor-Leste',
|
||||||
|
'Togo',
|
||||||
|
'Tonga',
|
||||||
|
'Trinidad and Tobago',
|
||||||
|
'Tunisia',
|
||||||
|
'Turkey',
|
||||||
|
'Turkmenistan',
|
||||||
|
'Tuvalu',
|
||||||
|
'Uganda',
|
||||||
|
'Ukraine',
|
||||||
|
'United Arab Emirates',
|
||||||
|
'United Kingdom',
|
||||||
|
'United States',
|
||||||
|
'Uruguay',
|
||||||
|
'Uzbekistan',
|
||||||
|
'Vanuatu',
|
||||||
|
'Vatican City',
|
||||||
|
'Venezuela',
|
||||||
|
'Vietnam',
|
||||||
|
'Yemen',
|
||||||
|
'Zambia',
|
||||||
|
'Zimbabwe'
|
||||||
|
];
|
||||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { authAPI } from '../services/api';
|
import { authAPI } from '../services/api';
|
||||||
import Layout from '../components/layout/Layout';
|
import Layout from '../components/layout/Layout';
|
||||||
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2, Hash, Youtube, Instagram, Facebook } from 'lucide-react';
|
import { User, Mail, Lock, Save, AlertCircle, CheckCircle, Loader2, Hash, Youtube, Instagram, Facebook, MapPin, Globe } from 'lucide-react';
|
||||||
|
import { COUNTRIES } from '../data/countries';
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { user, updateUser } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
@@ -18,6 +19,8 @@ const ProfilePage = () => {
|
|||||||
instagramUrl: '',
|
instagramUrl: '',
|
||||||
facebookUrl: '',
|
facebookUrl: '',
|
||||||
tiktokUrl: '',
|
tiktokUrl: '',
|
||||||
|
country: '',
|
||||||
|
city: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load user data when component mounts or user changes
|
// Load user data when component mounts or user changes
|
||||||
@@ -32,6 +35,8 @@ const ProfilePage = () => {
|
|||||||
instagramUrl: user.instagramUrl || '',
|
instagramUrl: user.instagramUrl || '',
|
||||||
facebookUrl: user.facebookUrl || '',
|
facebookUrl: user.facebookUrl || '',
|
||||||
tiktokUrl: user.tiktokUrl || '',
|
tiktokUrl: user.tiktokUrl || '',
|
||||||
|
country: user.country || '',
|
||||||
|
city: user.city || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -231,6 +236,54 @@ const ProfilePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Location Section */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Country */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Country
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Globe className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
name="country"
|
||||||
|
value={profileData.country}
|
||||||
|
onChange={handleProfileChange}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a country</option>
|
||||||
|
{COUNTRIES.map((country) => (
|
||||||
|
<option key={country} value={country}>
|
||||||
|
{country}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
City
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<MapPin className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="city"
|
||||||
|
value={profileData.city}
|
||||||
|
onChange={handleProfileChange}
|
||||||
|
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Somewhere"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* WSDC ID */}
|
{/* WSDC ID */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user