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 && (
+
+ )}
{isOwnProfile && (
) : (
- replies.map((reply) => (
-
-
-
-
- {reply.user_id ? (
-
-
- {reply.photo ? (
-

- ) : (
-
- {getAuthorInitial(reply.username)}
-
- )}
-
-
- ) : (
-
-
- {reply.photo ? (
-

- ) : (
-
- {getAuthorInitial(reply.username)}
-
- )}
-
-
- )}
-
- {reply.user_id ? (
-
- {reply.username || "Anonymous"}
-
- ) : (
-
- {reply.username || "Anonymous"}
-
- )}
-
-
-
-
-
- {currentUserId && reply.user_id === currentUserId && (
-
handleDeleteReply(reply.id)}
- >
-
- Delete
-
- )}
-
-
{reply.content}
-
-
- ))
+ topLevelReplies.map((reply) => renderReply(reply, 0))
)}
diff --git a/frontend/src/pages/public/travel-advice/forums/ThreadsList.tsx b/frontend/src/pages/public/travel-advice/forums/ThreadsList.tsx
index 635ab4d..2fb9711 100644
--- a/frontend/src/pages/public/travel-advice/forums/ThreadsList.tsx
+++ b/frontend/src/pages/public/travel-advice/forums/ThreadsList.tsx
@@ -7,9 +7,19 @@ import Pagination from "../../../../components/globals/Pagination";
import SearchSortFilterCard from "../../../../components/globals/SearchSortFilterCard";
import { DateTimeDisplay } from "../../../../utils/formatDateTime";
import { getLocaleFromPathname, localizePath } from "../../../../utils/localeRouting";
+import { getFlagUrl } from "../../../../utils/countries";
import type { Thread } from "./types";
+type CountryCodeCarrier = {
+ country_code?: string | null;
+ countryCode?: string | null;
+ profile?: {
+ country_code?: string | null;
+ countryCode?: string | null;
+ } | null;
+};
+
type ThreadsListProps = {
threads: Thread[];
loading: boolean;
@@ -125,6 +135,13 @@ const ThreadsList = ({
{ value: "desc", label: descendingLabel },
{ value: "asc", label: ascendingLabel },
];
+ const getCountryCode = (value: CountryCodeCarrier) => {
+ return value.country_code
+ ?? value.countryCode
+ ?? value.profile?.country_code
+ ?? value.profile?.countryCode
+ ?? null;
+ };
return (
@@ -242,6 +259,8 @@ const ThreadsList = ({
currentUserId && thread.user_id === currentUserId,
);
const isEditing = editingThreadId === thread.id;
+ const threadCountryCode = getCountryCode(thread as Thread & CountryCodeCarrier);
+ const threadFlagUrl = getFlagUrl(threadCountryCode, 64);
return (
{thread.username || "Anonymous"}
)}
+ {threadFlagUrl && (
+
+ )}
•
{
+ if (!value) return null;
+ const normalized = value.trim().toUpperCase();
+ return /^[A-Z]{2}$/.test(normalized) ? normalized : null;
+};
+
+export const getFlagUrl = (countryCode?: string | null, size = 24): string | null => {
+ const normalized = normalizeCountryCode(countryCode);
+ if (!normalized) return null;
+ return `https://flagsapi.com/${normalized}/flat/${size}.png`;
+};
+
+const getDisplayName = (code: string, displayNames?: Intl.DisplayNames): string => {
+ return displayNames?.of(code) ?? code;
+};
+
+export const getCountryOptions = (locale = "en"): CountryOption[] => {
+ let displayNames: Intl.DisplayNames | undefined;
+ if (typeof Intl !== "undefined" && typeof Intl.DisplayNames === "function") {
+ try {
+ displayNames = new Intl.DisplayNames([locale], { type: "region" });
+ } catch {
+ displayNames = undefined;
+ }
+ }
+
+ // Intl.supportedValuesOf does not support "region" keys; use a curated fallback list.
+ const regionCodes = FALLBACK_COUNTRY_CODES;
+
+ const uniqueCodes = Array.from(
+ new Set(regionCodes.map((code) => normalizeCountryCode(code)).filter((code): code is string => Boolean(code))),
+ );
+
+ return uniqueCodes
+ .map((code) => ({
+ code,
+ name: getDisplayName(code, displayNames),
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+};