Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions backend/src/db/handlers/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type Profile = {
email?: string | null;
username?: string;
photo?: string | null;
settings?: Record<string, any> | null;
settings?: Record<string, unknown> | null;
bio?: string | null;
};

Expand Down Expand Up @@ -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];
Expand All @@ -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];
Expand All @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/routes/authenticated/forums/replies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions backend/src/routes/authenticated/forums/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
53 changes: 51 additions & 2 deletions backend/src/routes/authenticated/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>)
: null;
} catch {
return null;
}
}

if (typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}

return null;
};

// Personal profile routes

router.get("/me", authenticateToken, async (_req, res) => {
Expand All @@ -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<string, unknown>)
: {};
const mergedSettings: Record<string, unknown> = {
...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,
};

Expand Down
58 changes: 56 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(?:\/[^/]+)?\/?$/,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -125,7 +179,7 @@ const LocaleGate = () => {

return (
<Navigate
to={`${localizePath("/", langQuery?.locale ?? DEFAULT_LOCALE)}${langQuery?.search ?? ""}${location.hash}`}
to={`${localizePath("/", langQuery?.locale ?? getUserDefaultLocale())}${langQuery?.search ?? ""}${location.hash}`}
replace
/>
);
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/components/i18n/LangToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
};
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading