diff --git a/backend/src/db/handlers/profiles.ts b/backend/src/db/handlers/profiles.ts index 0229028..8c1c72e 100644 --- a/backend/src/db/handlers/profiles.ts +++ b/backend/src/db/handlers/profiles.ts @@ -7,7 +7,7 @@ type Profile = { email?: string | null; username?: string; photo?: string | null; - settings?: Record | null; + settings?: Record | null; bio?: string | null; }; @@ -50,7 +50,16 @@ export const uploadAvatar = async ( const getProfileById = async (userId: string) => { try { const result = await pool.query( - "SELECT id, photo, username, bio FROM profiles WHERE id = $1", + ` + SELECT + id, + photo, + username, + bio, + settings->>'country_code' AS country_code + FROM profiles + WHERE id = $1 + `, [userId] ); return result.rows[0]; @@ -63,7 +72,18 @@ const getProfileById = async (userId: string) => { const getMyProfile = async (userId: string) => { try { const result = await pool.query( - "SELECT id, photo, username, email, settings, bio FROM profiles WHERE id = $1", + ` + SELECT + id, + photo, + username, + email, + settings, + bio, + settings->>'country_code' AS country_code + FROM profiles + WHERE id = $1 + `, [userId] ); return result.rows[0]; @@ -79,9 +99,9 @@ const createProfile = async (userProfile: Profile) => { } const result = await pool.query( ` - INSERT INTO profiles (id, email, username, photo, settings, bio, created_at, updated_at) + INSERT INTO profiles (id, email, username, photo, settings, bio, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) - RETURNING id, email, username, photo, settings, bio + RETURNING id, email, username, photo, settings, bio, settings->>'country_code' AS country_code `, [ userProfile.userId, @@ -105,7 +125,7 @@ const updateProfile = async (userProfile: Profile) => { bio = $5, updated_at = NOW() WHERE id = $1 - RETURNING * + RETURNING id, email, username, photo, settings, bio, settings->>'country_code' AS country_code `, [ userProfile.userId, diff --git a/backend/src/routes/authenticated/forums/replies.ts b/backend/src/routes/authenticated/forums/replies.ts index 310e009..40a6e09 100644 --- a/backend/src/routes/authenticated/forums/replies.ts +++ b/backend/src/routes/authenticated/forums/replies.ts @@ -40,7 +40,8 @@ router.get("/", async (req, res) => { r.parent_reply_id, r.created_at, p.username, - p.photo + p.photo, + p.settings->>'country_code' AS country_code FROM public.replies r LEFT JOIN public.profiles p ON p.id = r.user_id WHERE r.thread_id = $1 diff --git a/backend/src/routes/authenticated/forums/threads.ts b/backend/src/routes/authenticated/forums/threads.ts index 9404b36..c2f4d6a 100644 --- a/backend/src/routes/authenticated/forums/threads.ts +++ b/backend/src/routes/authenticated/forums/threads.ts @@ -72,7 +72,8 @@ router.get("/", async (req, res) => { t.created_at, t.updated_at, p.username, - p.photo + p.photo, + p.settings->>'country_code' AS country_code FROM public.threads t LEFT JOIN public.profiles p ON p.id = t.user_id ${whereSql} @@ -113,7 +114,8 @@ router.get("/:id", async (req, res) => { t.created_at, t.updated_at, p.username, - p.photo + p.photo, + p.settings->>'country_code' AS country_code FROM public.threads t LEFT JOIN public.profiles p ON p.id = t.user_id WHERE t.id = $1 diff --git a/backend/src/routes/authenticated/profiles.ts b/backend/src/routes/authenticated/profiles.ts index 4710de2..cdda23b 100644 --- a/backend/src/routes/authenticated/profiles.ts +++ b/backend/src/routes/authenticated/profiles.ts @@ -15,6 +15,33 @@ import { authenticateToken } from "../../middlewares/authenticator"; const router = Router(); const upload = multer({ storage: multer.memoryStorage() }); +const normalizeCountryCode = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const normalized = value.trim().toUpperCase(); + return /^[A-Z]{2}$/.test(normalized) ? normalized : null; +}; + +const parseSettingsInput = (value: unknown): Record | null => { + if (!value) return null; + + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + + return null; +}; + // Personal profile routes router.get("/me", authenticateToken, async (_req, res) => { @@ -41,19 +68,41 @@ router.patch( try { const userId = req.user.sub; - const existingProfile = await getProfileById(userId); + const existingProfile = await getMyProfile(userId); if (!existingProfile) { return res.status(404).json({ error: "Profile not found" }); } const photoUrl = await uploadAvatar(userId, req.file); + const hasCountryCodeField = Object.prototype.hasOwnProperty.call( + req.body, + "country_code", + ); + const normalizedCountryCode = normalizeCountryCode(req.body.country_code); + const incomingSettings = parseSettingsInput(req.body.settings); + const existingSettings = + existingProfile.settings && typeof existingProfile.settings === "object" + ? (existingProfile.settings as Record) + : {}; + const mergedSettings: Record = { + ...existingSettings, + ...(incomingSettings ?? {}), + }; + + if (hasCountryCodeField) { + if (normalizedCountryCode) { + mergedSettings.country_code = normalizedCountryCode; + } else { + delete mergedSettings.country_code; + } + } const updatedProfile = { userId, username: req.body.username ?? existingProfile.username, photo: photoUrl ?? existingProfile.photo, - settings: req.body.settings ?? existingProfile.settings ?? null, // never undefined + settings: Object.keys(mergedSettings).length > 0 ? mergedSettings : null, bio: req.body.bio ?? existingProfile.bio ?? null, }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4463e21..86463d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,10 +20,64 @@ import { DEFAULT_LOCALE, normalizeLocale, resolvePathLocaleSegment, + type AppLocale, } from "./i18n/locales"; import { localizePath, parseLocaleFromPathname } from "./utils/localeRouting"; import TopAttractions from "./pages/public/explore/TopAttractions"; +const PROFILE_STORAGE_KEY = "profile"; +const HANT_DEFAULT_COUNTRY_CODES = new Set(["HK", "CN", "TW"]); + +const resolveLocaleFromCountryCode = ( + countryCode: string | null | undefined, +): AppLocale => { + const normalized = countryCode?.trim().toUpperCase() ?? ""; + return HANT_DEFAULT_COUNTRY_CODES.has(normalized) + ? "zh-Hant-HK" + : "en-GB"; +}; + +const getUserDefaultLocale = (): AppLocale => { + if (typeof window === "undefined") return DEFAULT_LOCALE; + + try { + const rawProfile = window.localStorage.getItem(PROFILE_STORAGE_KEY); + if (!rawProfile) return DEFAULT_LOCALE; + + const parsedProfile = JSON.parse(rawProfile) as { + settings?: { + last_used_locale?: unknown; + country_code?: unknown; + }; + country_code?: unknown; + countryCode?: unknown; + }; + + const localeFromSettings = normalizeLocale( + typeof parsedProfile.settings?.last_used_locale === "string" + ? parsedProfile.settings.last_used_locale + : null, + ); + + if (localeFromSettings) { + return localeFromSettings; + } + + const countryCode = + typeof parsedProfile.settings?.country_code === "string" + ? parsedProfile.settings.country_code + : typeof parsedProfile.country_code === "string" + ? parsedProfile.country_code + : typeof parsedProfile.countryCode === "string" + ? parsedProfile.countryCode + : null; + + return resolveLocaleFromCountryCode(countryCode); + } catch { + return DEFAULT_LOCALE; + } +}; + const KNOWN_UNLOCALIZED_PATH_PATTERNS = [ /^\/$/, /^\/events(?:\/[^/]+)?\/?$/, @@ -68,7 +122,7 @@ const resolveLangQuery = (search: string) => { const MissingLocaleRedirect = () => { const location = useLocation(); const langQuery = resolveLangQuery(location.search); - const nextLocale = langQuery?.locale ?? DEFAULT_LOCALE; + const nextLocale = langQuery?.locale ?? getUserDefaultLocale(); const nextSearch = langQuery?.search ?? location.search; return ( @@ -125,7 +179,7 @@ const LocaleGate = () => { return ( ); diff --git a/frontend/src/components/i18n/LangToggler.tsx b/frontend/src/components/i18n/LangToggler.tsx index 71b2f22..16fa584 100644 --- a/frontend/src/components/i18n/LangToggler.tsx +++ b/frontend/src/components/i18n/LangToggler.tsx @@ -5,9 +5,12 @@ import { useState, useRef, useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import type { AppLocale } from "../../i18n/locales"; import { replaceLocaleInPathname } from "../../utils/localeRouting"; +import { useAuth } from "../../context/AuthContext"; +import CONFIG from "../../config"; const LangToggler = () => { const { i18n, t } = useTranslation(); + const { token, profile, refreshProfile } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const [open, setOpen] = useState(false); @@ -32,6 +35,31 @@ const LangToggler = () => { const changeLang = async (lng: AppLocale) => { const nextPathname = replaceLocaleInPathname(location.pathname, lng); await i18n.changeLanguage(lng); + + if (token && profile) { + try { + const mergedSettings = { + ...(profile.settings ?? {}), + last_used_locale: lng, + }; + + await fetch(`${CONFIG.API_BASE_URL}/profiles/me`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + settings: mergedSettings, + }), + }); + + await refreshProfile(); + } catch { + // Ignore locale preference persistence failures and continue navigation. + } + } + navigate(`${nextPathname}${location.search}${location.hash}`); setOpen(false); }; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 6c7e8e5..fd4f498 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -9,6 +9,12 @@ export type ProfileType = { username: string; photo: string | null; bio: string | null; + country_code: string | null; + settings: { + country_code?: string; + last_used_locale?: string; + [key: string]: unknown; + } | null; }; type AuthContextType = { diff --git a/frontend/src/pages/authenticated/Profile.tsx b/frontend/src/pages/authenticated/Profile.tsx index aa26b3a..4383c06 100644 --- a/frontend/src/pages/authenticated/Profile.tsx +++ b/frontend/src/pages/authenticated/Profile.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useParams } from "react-router-dom"; import CONFIG from "../../config"; @@ -11,6 +11,11 @@ import type { Thread, ThreadsResponse } from "../public/travel-advice/forums/typ import Pagination from "../../components/globals/Pagination"; import { DateTimeDisplay } from "../../utils/formatDateTime"; import { getLocaleFromPathname, localizePath } from "../../utils/localeRouting"; +import { getCountryOptions, getFlagUrl, normalizeCountryCode } from "../../utils/countries"; + +const getProfileCountryCode = (profile: ProfileType | null) => { + return profile?.settings?.country_code ?? profile?.country_code ?? null; +}; const Profile = () => { const { t } = useTranslation(); @@ -34,6 +39,7 @@ const Profile = () => { const [editing, setEditing] = useState(false); const [editUsername, setEditUsername] = useState(""); const [editBio, setEditBio] = useState(""); + const [editCountryCode, setEditCountryCode] = useState(""); const [editPhoto, setEditPhoto] = useState(null); const [editPhotoPreview, setEditPhotoPreview] = useState(null); const [saving, setSaving] = useState(false); @@ -44,6 +50,8 @@ const Profile = () => { const [threadsLoading, setThreadsLoading] = useState(false); const [threadsError, setThreadsError] = useState(null); + const countryOptions = useMemo(() => getCountryOptions(locale), [locale]); + const photoInputRef = useRef(null); const getProfile = useCallback(async () => { @@ -132,6 +140,7 @@ const Profile = () => { if (!currentProfile) return; setEditUsername(currentProfile.username); setEditBio(currentProfile.bio ?? ""); + setEditCountryCode(getProfileCountryCode(currentProfile) ?? ""); setEditPhoto(null); setEditPhotoPreview(null); setSaveError(null); @@ -161,6 +170,7 @@ const Profile = () => { const formData = new FormData(); formData.append("username", editUsername); formData.append("bio", editBio); + formData.append("country_code", normalizeCountryCode(editCountryCode) ?? ""); if (editPhoto) formData.append("photo", editPhoto); const res = await fetch(`${CONFIG.API_BASE_URL}/profiles/me`, { @@ -182,6 +192,8 @@ const Profile = () => { const avatarSrc = editPhotoPreview ?? currentProfile?.photo ?? null; const avatarFallback = currentProfile?.username?.[0]?.toUpperCase() ?? "?"; + const profileCountryCode = getProfileCountryCode(currentProfile); + const profileFlagUrl = getFlagUrl(profileCountryCode, 24); return ( @@ -235,6 +247,14 @@ const Profile = () => { <>

{currentProfile.username} + {profileFlagUrl && ( + {`${profileCountryCode} + )} {isOwnProfile && ( + )} + {currentUserId && reply.user_id === currentUserId && ( + + )} + + +

{reply.content}

+ + + + {canPost && replyTarget && replyTarget.replyId === reply.id && ( +
+
+ Replying to @{replyTarget.username} + +
+