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:
Radosław Gierwiało
2025-11-13 20:57:43 +01:00
parent 48f9dfe1b4
commit 144b13a0cf
7 changed files with 276 additions and 2 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "city" VARCHAR(100),
ADD COLUMN "country" VARCHAR(100);

View File

@@ -29,6 +29,10 @@ model User {
facebookUrl String? @map("facebook_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)
emailVerified Boolean @default(false) @map("email_verified")
verificationToken String? @unique @map("verification_token") @db.VarChar(255)

View File

@@ -10,7 +10,7 @@ const { sanitizeForEmail } = require('../utils/sanitize');
async function updateProfile(req, res, next) {
try {
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
const updateData = {};
@@ -21,6 +21,8 @@ async function updateProfile(req, res, next) {
if (instagramUrl !== undefined) updateData.instagramUrl = instagramUrl || null;
if (facebookUrl !== undefined) updateData.facebookUrl = facebookUrl || 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
const currentUser = await prisma.user.findUnique({

View File

@@ -168,6 +168,16 @@ const updateProfileValidation = [
return value.includes('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,
];

View File

@@ -24,6 +24,8 @@ router.get('/me', authenticate, async (req, res, next) => {
instagramUrl: true,
facebookUrl: true,
tiktokUrl: true,
country: true,
city: true,
avatar: true,
createdAt: true,
updatedAt: true,

View 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'
];

View File

@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { authAPI } from '../services/api';
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 { user, updateUser } = useAuth();
@@ -18,6 +19,8 @@ const ProfilePage = () => {
instagramUrl: '',
facebookUrl: '',
tiktokUrl: '',
country: '',
city: '',
});
// Load user data when component mounts or user changes
@@ -32,6 +35,8 @@ const ProfilePage = () => {
instagramUrl: user.instagramUrl || '',
facebookUrl: user.facebookUrl || '',
tiktokUrl: user.tiktokUrl || '',
country: user.country || '',
city: user.city || '',
});
}
}, [user]);
@@ -231,6 +236,54 @@ const ProfilePage = () => {
</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 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">