Skip to content
Open
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
5 changes: 3 additions & 2 deletions app/web/components/NativeMobileNavigationHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DEFAULT_LOCALE } from "i18n/locales";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useIsNativeEmbed } from "utils/nativeLink";
Expand All @@ -24,12 +25,12 @@ export default function NativeMobileNavigationHandler() {
if (data?.type === "MOBILE_NAVIGATE" && data?.path) {
// Extract locale and path (e.g., "/de/dashboard" -> locale="de", pathname="/dashboard")
const localeMatch = data.path.match(/^\/([a-z]{2}(-[A-Z][a-z]+)?)\//);
const locale = localeMatch ? localeMatch[1] : "en";
const locale = localeMatch ? localeMatch[1] : DEFAULT_LOCALE;
const pathname = localeMatch ? data.path.replace(/^\/[a-z]{2}(-[A-Z][a-z]+)?/, "") : data.path;

// Skip if already on the target route — prevents double-loading on initial render
// when the source URL already loaded the page and MOBILE_NAVIGATE arrives late
if (router.pathname === pathname && (router.locale || "en") === locale) {
if (router.pathname === pathname && (router.locale || DEFAULT_LOCALE) === locale) {
return;
}

Expand Down
5 changes: 3 additions & 2 deletions app/web/features/auth/login/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Alert from "components/Alert";
import HtmlMeta from "components/HtmlMeta";
import StyledLink from "components/StyledLink";
import { Trans, useTranslation } from "i18n";
import { DEFAULT_LOCALE, LOCALE_COOKIE_NAME } from "i18n/locales";
import { AUTH, GLOBAL, LANDING } from "i18n/namespaces";
import { useRouter } from "next/router";
import { useEffect } from "react";
Expand Down Expand Up @@ -50,11 +51,11 @@ export default function Login() {
typeof document !== "undefined"
? document.cookie
.split("; ")
.find((row) => row.startsWith("NEXT_LOCALE="))
.find((row) => row.startsWith(`${LOCALE_COOKIE_NAME}=`))
?.split("=")[1]
: null;

const targetLocale = nextLocale || router.locale || "en";
const targetLocale = nextLocale || router.locale || DEFAULT_LOCALE;

// Navigate to destination with user's preferred locale
router.push(redirectTo, undefined, { locale: targetLocale });
Expand Down
6 changes: 3 additions & 3 deletions app/web/features/translate/LanguagePickerSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Snackbar from "components/Snackbar";
import { useAuthContext } from "features/auth/AuthProvider";
import { useWeblateStats } from "features/weblate/useWeblateStats";
import { useTranslation } from "i18n";
import { LANGUAGE_MAP } from "i18n/constants";
import { LOCALE_AUTONYMS } from "i18n/locales";
import { GLOBAL } from "i18n/namespaces";
import { useRouter } from "next/router";
import { useState } from "react";
Expand Down Expand Up @@ -160,7 +160,7 @@ export default function LanguagePickerSelect({ displayMode = "round", onNavigate
display: "inline",
}}
>
{LANGUAGE_MAP[languageCode].nativeName}
{LOCALE_AUTONYMS[languageCode]}
</ListItemText>
</Stack>
<div>
Expand All @@ -187,7 +187,7 @@ export default function LanguagePickerSelect({ displayMode = "round", onNavigate
fontWeight: "bold",
}}
>
{LANGUAGE_MAP[selected].nativeName}
{LOCALE_AUTONYMS[selected]}
</Box>
);
return selectedDisplay;
Expand Down
12 changes: 6 additions & 6 deletions app/web/features/translate/TranslationProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import TranslateIcon from "@mui/icons-material/Translate";
import { Box, Card, CardContent, Chip, Link, styled, Typography, useMediaQuery } from "@mui/material";
import { useWeblateStats } from "features/weblate/useWeblateStats";
import { useTranslation } from "i18n";
import { LANGUAGE_MAP } from "i18n/constants";
import { LOCALE_AUTONYMS } from "i18n/locales";
import { GLOBAL } from "i18n/namespaces";
import React from "react";
import { translateJobURL } from "routes";
Expand Down Expand Up @@ -132,7 +132,7 @@ export default function TranslationProgress() {

// Filter and sort languages - show all with any progress
const availableLanguages = languages
.filter((language) => LANGUAGE_MAP[language.code.replace("_", "-")] && language.translated_percent > 0)
.filter((language) => LOCALE_AUTONYMS[language.code.replace("_", "-")] && language.translated_percent > 0)
.sort((a, b) => b.translated_percent - a.translated_percent); // Sort by completion percentage

return (
Expand Down Expand Up @@ -171,10 +171,10 @@ export default function TranslationProgress() {
</Box>
{availableLanguages.map((language) => {
const languageCode = language.code.replace("_", "-");
const languageInfo = LANGUAGE_MAP[languageCode];
const nativeName = LOCALE_AUTONYMS[languageCode];
const percent = language.translated_percent;

if (!languageInfo) return null;
if (!nativeName) return null;

return (
<React.Fragment key={language.code}>
Expand Down Expand Up @@ -203,7 +203,7 @@ export default function TranslationProgress() {
fontWeight: "bold",
}}
>
{languageInfo.nativeName}
{nativeName}
</Typography>
</Box>
<Chip
Expand Down Expand Up @@ -265,7 +265,7 @@ export default function TranslationProgress() {
fontWeight: "bold",
}}
>
{languageInfo.nativeName}
{nativeName}
</Typography>
<Typography
variant="caption"
Expand Down
2 changes: 1 addition & 1 deletion app/web/features/translate/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe("translate/utils", () => {
expect(codes).not.toContain("zh");
});

it("should filter out languages not in LANGUAGE_MAP", () => {
it("should filter out languages not in LOCALE_AUTONYMS", () => {
const languagesWithUnmapped: WeblateLanguage[] = [
{ code: "en", translated_percent: 100 },
{ code: "xx_FAKE", translated_percent: 90 },
Expand Down
6 changes: 3 additions & 3 deletions app/web/features/translate/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LANGUAGE_MAP } from "i18n/constants";
import { ALWAYS_AVAILABLE_LOCALES, LOCALE_AUTONYMS } from "i18n/locales";

import { ALMOST_DONE_CUTOFF, SELECTOR_CUTOFF } from "./constants";

Expand All @@ -12,7 +12,7 @@ export interface WeblateLanguage {
*/
export function isLanguageProductionReady(locale: string, languages: WeblateLanguage[] | undefined): boolean {
// English is always production-ready
if (locale === "en") {
if (ALWAYS_AVAILABLE_LOCALES.includes(locale)) {
return true;
}

Expand Down Expand Up @@ -44,7 +44,7 @@ export function getAvailableLanguages(
return languages
.filter(
(language) =>
LANGUAGE_MAP[language.code.replace("_", "-")] && (showAll || language.translated_percent >= SELECTOR_CUTOFF),
LOCALE_AUTONYMS[language.code.replace("_", "-")] && (showAll || language.translated_percent >= SELECTOR_CUTOFF),
)
.sort((a, b) => {
// Sort by translation percentage (>= 80% first), then alphabetically
Expand Down
81 changes: 0 additions & 81 deletions app/web/i18n/constants.ts

This file was deleted.

35 changes: 35 additions & 0 deletions app/web/i18n/locales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// The name of the cookie storing the current locale.
export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";

// Final fallback locale if none other matches. Always available.
export const DEFAULT_LOCALE = "en";

// Locales which don't rely on translation progress.
export const ALWAYS_AVAILABLE_LOCALES = ["en"];

// Autonym = a language name as written in its own language.
export const LOCALE_AUTONYMS: Record<string, string> = {
ca: "Català",
cs: "Čeština",
de: "Deutsch",
en: "English",
es: "Español (España)",
"es-419": "Español (Latinoamérica)",
fr: "Français",
he: "עברית",
hi: "हिन्दी",
hu: "Magyar",
it: "Italiano",
ja: "日本語",
"nb-NO": "Norsk (bokmål)",
nl: "Nederlands",
pl: "Polski",
pt: "Português (Portugal)",
"pt-BR": "Português (Brasil)",
ru: "Русский",
sv: "Svenska",
tr: "Türkçe",
uk: "Українська",
"zh-Hans": "中文(简体)",
"zh-Hant": "中文(繁體)",
};
3 changes: 2 additions & 1 deletion app/web/i18n/server-side-translations.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { DEFAULT_LOCALE } from "i18n/locales";
import { GetStaticProps } from "next";
import nextI18NextConfig from "next-i18next.config";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";

const serverSideTranslationProps = async (locale: string | undefined, namespaces: Array<string>) =>
await serverSideTranslations(locale ?? "en", namespaces, nextI18NextConfig);
await serverSideTranslations(locale ?? DEFAULT_LOCALE, namespaces, nextI18NextConfig);

export const translationStaticProps =
(namespaces: Array<string>): GetStaticProps =>
Expand Down
24 changes: 14 additions & 10 deletions app/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
import { sessionCookieName } from "./appConstants";
import { ALMOST_DONE_CUTOFF } from "./features/translate/constants";
import { fetchWeblateStats, WeblateLanguage } from "./features/weblate/useWeblateStats";
import { ALWAYS_AVAILABLE_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE_NAME } from "./i18n/locales";

// In-memory cache for Weblate stats
let statsCache: {
Expand Down Expand Up @@ -50,7 +51,9 @@ async function getProductionReadyLocales(): Promise<string[]> {
.map((lang) => lang.code.replace("_", "-"));

// English is always production-ready
return allLanguages.filter((locale) => locale === "en" || productionReadyLocales.includes(locale));
return allLanguages.filter(

@nabramow nabramow Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit - can do in another PR, but a) we should maybe abstract this to named constants as it took me a min to figure out what it's doing and, b) under the hood it's a loop within a loop. ALWAYS_AVAILABLE_LOCALES has only one item in it so fine, but I do wonder if we can achieve this in a more succinct way?

Especially as this file is high traffic, good to keep it clear.

(locale) => ALWAYS_AVAILABLE_LOCALES.includes(locale) || productionReadyLocales.includes(locale),
);
}

/**
Expand All @@ -75,7 +78,7 @@ export function shouldBlockIncompleteLanguage(
cookieLocale: string | undefined,
isProductionReady: boolean,
): boolean {
if (currentLocale === "en") {
if (ALWAYS_AVAILABLE_LOCALES.includes(currentLocale)) {
return false;
}

Expand All @@ -98,7 +101,7 @@ export function getBrowserLocaleFromHeader(

async function getBestLocale(request: NextRequest): Promise<string> {
// Priority 1: NEXT_LOCALE cookie (set by backend or language picker)
const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value;
const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value;
if (cookieLocale && allLanguages.includes(cookieLocale)) {
return cookieLocale;
}
Expand All @@ -108,12 +111,12 @@ async function getBestLocale(request: NextRequest): Promise<string> {
// falling through to the next preferred language in the header otherwise
const acceptLanguage = request.headers.get("accept-language");
const productionReadyLocales = await getProductionReadyLocales();
return getBrowserLocaleFromHeader(acceptLanguage || undefined, productionReadyLocales) || "en";
return getBrowserLocaleFromHeader(acceptLanguage || undefined, productionReadyLocales) || DEFAULT_LOCALE;
}

export async function middleware(request: NextRequest) {
const { pathname, locale: currentLocale } = request.nextUrl;
const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value;
const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value;
const isAuthenticated = !!request.cookies.get(sessionCookieName);

// Skip locale redirect if this is a client-side navigation
Expand All @@ -126,14 +129,14 @@ export async function middleware(request: NextRequest) {

if (shouldBlock) {
const url = request.nextUrl.clone();
url.locale = "en";
url.locale = DEFAULT_LOCALE;

if (isAuthenticated && pathname === "/") {
url.pathname = "/dashboard";
}

const response = NextResponse.redirect(url);
response.cookies.set("NEXT_LOCALE", "en", {
response.cookies.set(LOCALE_COOKIE_NAME, DEFAULT_LOCALE, {
path: "/",
maxAge: 31536000, // 1 year
sameSite: "lax",
Expand All @@ -146,7 +149,8 @@ export async function middleware(request: NextRequest) {

if (cookieLocale && allLanguages.includes(cookieLocale)) {
targetLocale = cookieLocale;
} else if (currentLocale !== "en") {
} else if (currentLocale !== DEFAULT_LOCALE) {
// The locale is explicit in the URL
targetLocale = currentLocale;
} else {
targetLocale = await getBestLocale(request);
Expand All @@ -164,7 +168,7 @@ export async function middleware(request: NextRequest) {
}

const response = NextResponse.redirect(url);
response.cookies.set("NEXT_LOCALE", targetLocale, {
response.cookies.set(LOCALE_COOKIE_NAME, targetLocale, {
path: "/",
maxAge: 31536000,
sameSite: "lax",
Expand All @@ -182,7 +186,7 @@ export async function middleware(request: NextRequest) {
// Set cookie if it doesn't exist yet
if (!cookieLocale) {
const response = NextResponse.next();
response.cookies.set("NEXT_LOCALE", currentLocale, {
response.cookies.set(LOCALE_COOKIE_NAME, currentLocale, {
path: "/",
maxAge: 31536000, // 1 year
sameSite: "lax",
Expand Down
3 changes: 2 additions & 1 deletion app/web/pages/404.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { appGetLayout } from "components/AppRoute";
import NotFoundPage from "features/NotFoundPage";
import { appServerSideTranslations } from "i18n/appServerSideTranslations";
import { DEFAULT_LOCALE } from "i18n/locales";
import { GLOBAL, NOTIFICATIONS } from "i18n/namespaces";
import { GetStaticProps } from "next";

export const getStaticProps: GetStaticProps = async ({ locale }) => {
return {
props: {
...(await appServerSideTranslations(locale ?? "en", [GLOBAL, NOTIFICATIONS])),
...(await appServerSideTranslations(locale ?? DEFAULT_LOCALE, [GLOBAL, NOTIFICATIONS])),
},
};
};
Expand Down
Loading
Loading