@@ -20,14 +21,15 @@ export function OAuthButtons({ label }: OAuthButtonsProps) {
href="/auth/oauth/google/start"
className="flex w-full items-center justify-center gap-2 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
- {label} with Google
+ {label} {t('common.withGoogle')}
- {label} with GitHub
+ {label} {t('common.withGitHub')}
);
}
+import { useLocale } from '#/lib/i18n';
diff --git a/apps/web/src/i18n/en-GB.json b/apps/web/src/i18n/en-GB.json
new file mode 100644
index 00000000..aa9a7255
--- /dev/null
+++ b/apps/web/src/i18n/en-GB.json
@@ -0,0 +1,39 @@
+{
+ "nav.dashboard": "Dashboard",
+ "nav.applications": "Applications",
+ "nav.calendar": "Calendar",
+ "nav.analytics": "Analytics",
+ "nav.assistant": "Assistant",
+ "nav.settings": "Settings",
+ "nav.signOut": "Sign out",
+ "settings.language": "Language",
+ "settings.languageDescription": "Choose the language used across the web app.",
+ "settings.profile": "Profile",
+ "settings.experience": "Experience",
+ "settings.security": "Security",
+ "settings.integrations": "Integrations",
+ "settings.notifications": "Notifications",
+ "settings.data": "Data",
+ "errors.notFound": "Page not found",
+ "errors.goDashboard": "Go to dashboard",
+ "errors.somethingWrong": "Something went wrong",
+ "errors.reloadHelp": "Try reloading the page. If this keeps happening, let us know.",
+ "errors.reload": "Reload",
+ "auth.signIn": "Sign in",
+ "auth.signUp": "Sign up",
+ "auth.email": "Email",
+ "auth.password": "Password",
+ "auth.forgotPassword": "Forgot password?",
+ "common.save": "Save",
+ "common.cancel": "Cancel",
+ "common.delete": "Delete",
+ "common.loading": "Loading…",
+ "auth.noAccount": "Don't have an account?",
+ "auth.register": "Register",
+ "auth.twoFactor": "Two-factor authentication",
+ "auth.twoFactorHelp": "Enter the 6-digit code from your authenticator app, or one of your backup codes.",
+ "auth.code": "Code",
+ "auth.signingIn": "Signing in…",
+ "auth.noAccountFound": "No account found with this email.",
+ "auth.createOne": "to create one."
+}
diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json
new file mode 100644
index 00000000..4211d04e
--- /dev/null
+++ b/apps/web/src/i18n/en.json
@@ -0,0 +1,87 @@
+{
+ "nav.dashboard": "Dashboard",
+ "nav.applications": "Applications",
+ "nav.calendar": "Calendar",
+ "nav.analytics": "Analytics",
+ "nav.assistant": "Assistant",
+ "nav.settings": "Settings",
+ "nav.signOut": "Sign out",
+ "settings.language": "Language",
+ "settings.languageDescription": "Choose the language used across the web app.",
+ "settings.profile": "Profile",
+ "settings.experience": "Experience",
+ "settings.security": "Security",
+ "settings.integrations": "Integrations",
+ "settings.notifications": "Notifications",
+ "settings.data": "Data",
+ "errors.notFound": "Page not found",
+ "errors.goDashboard": "Go to dashboard",
+ "errors.somethingWrong": "Something went wrong",
+ "errors.reloadHelp": "Try reloading the page. If this keeps happening, let us know.",
+ "errors.reload": "Reload",
+ "errors.userNotFound": "No account found with this email. Please register first.",
+ "errors.itemNotFound": "That item couldn't be found — it may have been deleted.",
+ "errors.forbidden": "You don't have permission to do that.",
+ "errors.generic": "Something went wrong. Please try again.",
+ "errors.network": "Can't reach the server. Check your connection and try again.",
+ "auth.signIn": "Sign in",
+ "auth.signUp": "Sign up",
+ "auth.email": "Email",
+ "auth.password": "Password",
+ "auth.forgotPassword": "Forgot password?",
+ "common.save": "Save",
+ "common.cancel": "Cancel",
+ "common.delete": "Delete",
+ "common.loading": "Loading…",
+ "common.tryAgain": "Try again",
+ "common.undo": "Undo",
+ "common.or": "or",
+ "common.withGoogle": "with Google",
+ "common.withGitHub": "with GitHub",
+ "commands.dashboard": "Go to Dashboard",
+ "commands.applications": "Go to Applications",
+ "commands.newApplication": "New Application",
+ "commands.analytics": "Go to Analytics",
+ "commands.account": "Go to Account",
+ "commands.placeholder": "Type a command...",
+ "commands.noResults": "No results",
+ "status.draft": "Draft",
+ "status.applied": "Applied",
+ "status.interviewing": "Interviewing",
+ "status.offered": "Offered",
+ "status.accepted": "Accepted",
+ "status.rejected": "Rejected",
+ "status.withdrawn": "Withdrawn",
+ "auth.noAccount": "Don't have an account?",
+ "auth.register": "Register",
+ "auth.twoFactor": "Two-factor authentication",
+ "auth.twoFactorHelp": "Enter the 6-digit code from your authenticator app, or one of your backup codes.",
+ "auth.code": "Code",
+ "auth.signingIn": "Signing in…",
+ "auth.noAccountFound": "No account found with this email.",
+ "auth.createOne": "to create one.",
+ "landing.signIn": "Sign in",
+ "landing.getStarted": "Get started",
+ "landing.startFree": "Start for free",
+ "landing.heroTitle": "Your job search, organized and powered by AI",
+ "landing.heroDescription": "Track applications, get AI-generated cover letters and resume feedback, visualize your pipeline, and never miss an interview. Everything you need to land your next role.",
+ "landing.tracking": "Application tracking",
+ "landing.trackingDescription": "Track every application from first apply to offer. Use a kanban board or list view, add notes, attach documents, and never lose sight of where you stand.",
+ "landing.assistant": "AI assistant",
+ "landing.assistantDescription": "Get personalized cover letters, resume match scores, and real-time answers about your applications — powered by the data you already have in Job Finder.",
+ "landing.analytics": "Analytics & insights",
+ "landing.analyticsDescription": "Visualize your pipeline with charts and stats. See conversion rates, time-to-response, and which sources deliver the best results.",
+ "landing.calendar": "Calendar",
+ "landing.calendarDescription": "Keep interviews and deadlines in one place. View your schedule by month, week, or day and never double-book again.",
+ "landing.documents": "Document management",
+ "landing.documentsDescription": "Upload and organize resumes, cover letters, and offer letters. Attach documents directly to applications for easy access.",
+ "landing.notifications": "Smart notifications",
+ "landing.notificationsDescription": "Stay on top of follow-ups and deadlines. Browser push notifications keep you informed even when the app is closed.",
+ "landing.everything": "Everything you need to land your next job",
+ "landing.everythingDescription": "Job Finder combines application tracking, AI-powered tools, and analytics into one seamless experience.",
+ "landing.howItWorks": "How it works",
+ "landing.step1": "Sign up for free in seconds",
+ "landing.step2": "Add your applications and documents",
+ "landing.step3": "Get AI-powered insights and recommendations",
+ "landing.step4": "Track progress from first apply to offer"
+}
diff --git a/apps/web/src/i18n/zh-CN.json b/apps/web/src/i18n/zh-CN.json
new file mode 100644
index 00000000..35de3fdc
--- /dev/null
+++ b/apps/web/src/i18n/zh-CN.json
@@ -0,0 +1,46 @@
+{
+ "nav.dashboard": "仪表盘",
+ "nav.applications": "求职申请",
+ "nav.calendar": "日历",
+ "nav.analytics": "分析",
+ "nav.assistant": "助手",
+ "nav.settings": "设置",
+ "nav.signOut": "退出登录",
+ "settings.language": "语言",
+ "settings.languageDescription": "选择网页应用使用的语言。",
+ "settings.profile": "个人资料",
+ "settings.experience": "经历",
+ "settings.security": "安全",
+ "settings.integrations": "集成",
+ "settings.notifications": "通知",
+ "settings.data": "数据",
+ "errors.notFound": "页面未找到",
+ "errors.goDashboard": "前往仪表盘",
+ "errors.somethingWrong": "发生错误",
+ "errors.reloadHelp": "请重新加载页面。如果问题持续,请通知我们。",
+ "errors.reload": "重新加载",
+ "auth.signIn": "登录",
+ "auth.signUp": "注册",
+ "auth.email": "电子邮箱",
+ "auth.password": "密码",
+ "auth.forgotPassword": "忘记密码?",
+ "common.save": "保存",
+ "common.cancel": "取消",
+ "common.delete": "删除",
+ "common.loading": "加载中…",
+ "status.draft": "草稿",
+ "status.applied": "已申请",
+ "status.interviewing": "面试中",
+ "status.offered": "已获得录用",
+ "status.accepted": "已接受",
+ "status.rejected": "已拒绝",
+ "status.withdrawn": "已撤回",
+ "auth.noAccount": "还没有账户?",
+ "auth.register": "注册",
+ "auth.twoFactor": "双重身份验证",
+ "auth.twoFactorHelp": "输入身份验证器应用中的六位数验证码,或一组备用验证码。",
+ "auth.code": "验证码",
+ "auth.signingIn": "登录中…",
+ "auth.noAccountFound": "未找到使用此邮箱的账户。",
+ "auth.createOne": "以创建账户。"
+}
diff --git a/apps/web/src/i18n/zh-HK.json b/apps/web/src/i18n/zh-HK.json
new file mode 100644
index 00000000..86142f2b
--- /dev/null
+++ b/apps/web/src/i18n/zh-HK.json
@@ -0,0 +1,46 @@
+{
+ "nav.dashboard": "主頁",
+ "nav.applications": "求職申請",
+ "nav.calendar": "日曆",
+ "nav.analytics": "分析",
+ "nav.assistant": "助理",
+ "nav.settings": "設定",
+ "nav.signOut": "登出",
+ "settings.language": "語言",
+ "settings.languageDescription": "選擇網頁應用程式使用的語言。",
+ "settings.profile": "個人資料",
+ "settings.experience": "經歷",
+ "settings.security": "安全性",
+ "settings.integrations": "整合",
+ "settings.notifications": "通知",
+ "settings.data": "資料",
+ "errors.notFound": "找不到頁面",
+ "errors.goDashboard": "前往主頁",
+ "errors.somethingWrong": "發生錯誤",
+ "errors.reloadHelp": "請重新載入頁面。如問題持續,請通知我們。",
+ "errors.reload": "重新載入",
+ "auth.signIn": "登入",
+ "auth.signUp": "註冊",
+ "auth.email": "電郵",
+ "auth.password": "密碼",
+ "auth.forgotPassword": "忘記密碼?",
+ "common.save": "儲存",
+ "common.cancel": "取消",
+ "common.delete": "刪除",
+ "common.loading": "載入中…",
+ "status.draft": "草稿",
+ "status.applied": "已申請",
+ "status.interviewing": "面試中",
+ "status.offered": "已獲取錄",
+ "status.accepted": "已接受",
+ "status.rejected": "已拒絕",
+ "status.withdrawn": "已撤回",
+ "auth.noAccount": "還未有帳戶?",
+ "auth.register": "註冊",
+ "auth.twoFactor": "雙重認證",
+ "auth.twoFactorHelp": "輸入認證器應用程式中的六位數代碼,或其中一組備用代碼。",
+ "auth.code": "代碼",
+ "auth.signingIn": "登入中…",
+ "auth.noAccountFound": "找不到此電郵的帳戶。",
+ "auth.createOne": "以建立帳戶。"
+}
diff --git a/apps/web/src/i18n/zh-TW.json b/apps/web/src/i18n/zh-TW.json
new file mode 100644
index 00000000..985c1229
--- /dev/null
+++ b/apps/web/src/i18n/zh-TW.json
@@ -0,0 +1,46 @@
+{
+ "nav.dashboard": "儀表板",
+ "nav.applications": "求職申請",
+ "nav.calendar": "行事曆",
+ "nav.analytics": "分析",
+ "nav.assistant": "助理",
+ "nav.settings": "設定",
+ "nav.signOut": "登出",
+ "settings.language": "語言",
+ "settings.languageDescription": "選擇網頁應用程式使用的語言。",
+ "settings.profile": "個人資料",
+ "settings.experience": "經歷",
+ "settings.security": "安全性",
+ "settings.integrations": "整合",
+ "settings.notifications": "通知",
+ "settings.data": "資料",
+ "errors.notFound": "找不到頁面",
+ "errors.goDashboard": "前往儀表板",
+ "errors.somethingWrong": "發生錯誤",
+ "errors.reloadHelp": "請重新載入頁面。如果問題持續,請通知我們。",
+ "errors.reload": "重新載入",
+ "auth.signIn": "登入",
+ "auth.signUp": "註冊",
+ "auth.email": "電子郵件",
+ "auth.password": "密碼",
+ "auth.forgotPassword": "忘記密碼?",
+ "common.save": "儲存",
+ "common.cancel": "取消",
+ "common.delete": "刪除",
+ "common.loading": "載入中…",
+ "status.draft": "草稿",
+ "status.applied": "已申請",
+ "status.interviewing": "面試中",
+ "status.offered": "已獲得聘用",
+ "status.accepted": "已接受",
+ "status.rejected": "已拒絕",
+ "status.withdrawn": "已撤回",
+ "auth.noAccount": "還沒有帳戶?",
+ "auth.register": "註冊",
+ "auth.twoFactor": "雙因素驗證",
+ "auth.twoFactorHelp": "輸入驗證器應用程式中的六位數代碼,或其中一組備份代碼。",
+ "auth.code": "驗證碼",
+ "auth.signingIn": "登入中…",
+ "auth.noAccountFound": "找不到此電子郵件的帳戶。",
+ "auth.createOne": "以建立帳戶。"
+}
diff --git a/apps/web/src/lib/errors.ts b/apps/web/src/lib/errors.ts
index 34dae16c..d44a0dfa 100644
--- a/apps/web/src/lib/errors.ts
+++ b/apps/web/src/lib/errors.ts
@@ -1,4 +1,6 @@
import { ERROR_CODES } from '#/constants';
+import i18next from 'i18next';
+import en from '#/i18n/en.json';
interface GraphQLErrorLike {
response: {
@@ -23,13 +25,20 @@ function isGraphQLErrorLike(error: unknown): error is GraphQLErrorLike {
// user-presentable prose from the use-case that threw it, so it's passed
// through as-is instead of being overridden here.
const CODE_MESSAGES: Record = {
- [ERROR_CODES.USER_NOT_FOUND]: 'No account found with this email. Please register first.',
- [ERROR_CODES.NOT_FOUND]: "That item couldn't be found — it may have been deleted.",
- [ERROR_CODES.FORBIDDEN]: "You don't have permission to do that.",
+ [ERROR_CODES.USER_NOT_FOUND]: 'errors.userNotFound',
+ [ERROR_CODES.NOT_FOUND]: 'errors.itemNotFound',
+ [ERROR_CODES.FORBIDDEN]: 'errors.forbidden',
};
-const GENERIC_MESSAGE = 'Something went wrong. Please try again.';
-const NETWORK_MESSAGE = "Can't reach the server. Check your connection and try again.";
+const GENERIC_MESSAGE = 'errors.generic';
+const NETWORK_MESSAGE = 'errors.network';
+const translate = (key: string) => {
+ const fallback = en[key as keyof typeof en] ?? key;
+ const translated = i18next.t(key, { defaultValue: fallback });
+ return typeof translated !== 'string' || translated === key || translated.length === 0
+ ? fallback
+ : translated;
+};
/**
* Turns any thrown error (GraphQL, network, or otherwise) into a single
@@ -42,14 +51,14 @@ export function getErrorMessage(error: unknown): string {
if (isGraphQLErrorLike(error)) {
const gqlError = error.response.errors?.[0];
const code = gqlError?.extensions?.code as string | undefined;
- if (code && code in CODE_MESSAGES) return CODE_MESSAGES[code];
+ if (code && code in CODE_MESSAGES) return translate(CODE_MESSAGES[code]);
if (code === ERROR_CODES.INTERNAL_ERROR || code === ERROR_CODES.SERVICE_UNAVAILABLE || !code) {
- return GENERIC_MESSAGE;
+ return translate(GENERIC_MESSAGE);
}
- return gqlError?.message ?? GENERIC_MESSAGE;
+ return gqlError?.message ?? translate(GENERIC_MESSAGE);
}
- if (error instanceof TypeError) return NETWORK_MESSAGE;
+ if (error instanceof TypeError) return translate(NETWORK_MESSAGE);
- return GENERIC_MESSAGE;
+ return translate(GENERIC_MESSAGE);
}
diff --git a/apps/web/src/lib/i18n.tsx b/apps/web/src/lib/i18n.tsx
new file mode 100644
index 00000000..43538931
--- /dev/null
+++ b/apps/web/src/lib/i18n.tsx
@@ -0,0 +1,161 @@
+import i18next from 'i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import { initReactI18next, I18nextProvider, useTranslation } from 'react-i18next';
+import { createContext, useContext, useEffect, useMemo, useState } from 'react';
+import en from '#/i18n/en.json';
+import enGB from '#/i18n/en-GB.json';
+import zhHK from '#/i18n/zh-HK.json';
+import zhTW from '#/i18n/zh-TW.json';
+import zhCN from '#/i18n/zh-CN.json';
+
+export const SUPPORTED_LOCALES = ['en', 'en-GB', 'zh-HK', 'zh-TW', 'zh-CN'] as const;
+export type Locale = (typeof SUPPORTED_LOCALES)[number];
+export const LOCALE_STORAGE_KEY = 'locale';
+export const LOCALE_QUERY_KEY = 'locale';
+
+export const LOCALE_OPTIONS: Array<{ value: Locale; label: string }> = [
+ { value: 'en', label: 'English' },
+ { value: 'en-GB', label: 'English (United Kingdom)' },
+ { value: 'zh-HK', label: '中文(香港)' },
+ { value: 'zh-TW', label: '繁體中文(台灣)' },
+ { value: 'zh-CN', label: '简体中文' },
+];
+
+const resources = {
+ en: { translation: en },
+ 'en-GB': { translation: enGB },
+ 'zh-HK': { translation: zhHK },
+ 'zh-TW': { translation: zhTW },
+ 'zh-CN': { translation: zhCN },
+};
+
+void i18next
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: 'en',
+ supportedLngs: SUPPORTED_LOCALES,
+ nonExplicitSupportedLngs: false,
+ interpolation: { escapeValue: false },
+ detection: {
+ order: ['querystring', 'navigator', 'localStorage'],
+ lookupQuerystring: LOCALE_QUERY_KEY,
+ lookupLocalStorage: LOCALE_STORAGE_KEY,
+ caches: ['localStorage'],
+ },
+ });
+
+export function isLocale(value: string | null | undefined): value is Locale {
+ return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
+}
+
+function normalizeLocale(value: string | undefined): Locale {
+ if (isLocale(value)) return value;
+ if (value?.startsWith('zh-HK')) return 'zh-HK';
+ if (value?.startsWith('zh-TW')) return 'zh-TW';
+ if (value?.startsWith('zh')) return 'zh-CN';
+ if (value?.startsWith('en-GB')) return 'en-GB';
+ return 'en';
+}
+
+function getInitialLocale(): Locale {
+ if (typeof window === 'undefined') return normalizeLocale(i18next.language);
+ const queryLocale = new URLSearchParams(window.location.search).get(LOCALE_QUERY_KEY);
+ return normalizeLocale(
+ queryLocale ?? localStorage.getItem(LOCALE_STORAGE_KEY) ?? i18next.language,
+ );
+}
+
+function writeLocaleToUrl(locale: Locale): void {
+ if (typeof window === 'undefined') return;
+ const url = new URL(window.location.href);
+ url.searchParams.set(LOCALE_QUERY_KEY, locale);
+ window.history.replaceState(window.history.state, '', url);
+}
+
+interface LocaleContextValue {
+ locale: Locale;
+ setLocale: (locale: Locale) => void;
+ t: (key: string) => string;
+ formatDate: (value: Date | string | number, options?: Intl.DateTimeFormatOptions) => string;
+ formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
+}
+
+export function LocaleProvider({ children }: { children: React.ReactNode }) {
+ const [locale, setLocaleState] = useState(getInitialLocale);
+
+ useEffect(() => {
+ const handleLanguageChanged = (next: string) => setLocaleState(normalizeLocale(next));
+ i18next.on('languageChanged', handleLanguageChanged);
+ return () => {
+ i18next.off('languageChanged', handleLanguageChanged);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (i18next.language !== locale) void i18next.changeLanguage(locale);
+ document.documentElement.lang = locale;
+ localStorage.setItem(LOCALE_STORAGE_KEY, locale);
+ writeLocaleToUrl(locale);
+ }, [locale]);
+
+ const value = useMemo(
+ () => ({
+ locale,
+ setLocale: (next) => {
+ writeLocaleToUrl(next);
+ void i18next.changeLanguage(next);
+ },
+ t: (key) => String(i18next.getFixedT(locale)(key)),
+ formatDate: (valueToFormat, options) =>
+ new Intl.DateTimeFormat(locale, options).format(
+ typeof valueToFormat === 'string' ? new Date(valueToFormat) : valueToFormat,
+ ),
+ formatNumber: (valueToFormat, options) =>
+ new Intl.NumberFormat(locale, options).format(valueToFormat),
+ }),
+ [locale],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+// Kept separate so existing component tests can render pages without adding a
+// provider while production pages still receive the i18next-backed context.
+const LocaleContext = createContext(null);
+
+function LocaleContextBridge({
+ value,
+ children,
+}: {
+ value: LocaleContextValue;
+ children: React.ReactNode;
+}) {
+ return {children};
+}
+
+export function useLocale(): LocaleContextValue {
+ const context = useContext(LocaleContext);
+ const translation = useTranslation();
+ if (context) return context;
+ const { i18n, t } = translation;
+ const locale = normalizeLocale(i18n.language);
+ return {
+ locale,
+ setLocale: (next) => void i18n.changeLanguage(next),
+ t: (key) => String(t(key)),
+ formatDate: (valueToFormat, options) =>
+ new Intl.DateTimeFormat(locale, options).format(
+ typeof valueToFormat === 'string' ? new Date(valueToFormat) : valueToFormat,
+ ),
+ formatNumber: (valueToFormat, options) =>
+ new Intl.NumberFormat(locale, options).format(valueToFormat),
+ };
+}
+
+export const LOCALE_INIT_SCRIPT = `(function(){try{var p=new URLSearchParams(location.search).get('${LOCALE_QUERY_KEY}');var l=p||localStorage.getItem('${LOCALE_STORAGE_KEY}')||((navigator.languages||[])[0])||'en';document.documentElement.lang=l;}catch(e){}})();`;
diff --git a/apps/web/src/lib/undoToast.ts b/apps/web/src/lib/undoToast.ts
index bccfd9b2..1f9fe538 100644
--- a/apps/web/src/lib/undoToast.ts
+++ b/apps/web/src/lib/undoToast.ts
@@ -1,4 +1,5 @@
import { toast } from 'sonner';
+import i18next from 'i18next';
type UndoToastOptions = {
message: string;
@@ -20,7 +21,7 @@ export function showUndoToast({ message, duration = 5000, onExecute, onUndo }: U
toast(message, {
duration,
action: {
- label: 'Undo',
+ label: i18next.t('common.undo'),
onClick: () => {
clearTimeout(timer);
activeTimers = activeTimers.filter((t) => t !== timer);
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 3f5e299f..8f10a3e0 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -7,6 +7,7 @@ import { Toaster } from 'sonner';
import { AlertTriangleIcon } from 'lucide-react';
import { queryClient } from '#/lib/queryClient';
import { THEME_INIT_SCRIPT, ThemeProvider, useTheme } from '#/lib/theme';
+import { LOCALE_INIT_SCRIPT, LocaleProvider, useLocale } from '#/lib/i18n';
import { NavigationProgressBar } from '#/components/NavigationProgressBar';
import { watchForServiceWorkerUpdate } from '#/lib/swUpdateToast';
@@ -41,13 +42,14 @@ export const Route = createRootRouteWithContext()({
});
function NotFound() {
+ const { t } = useLocale();
return (
404
-
Page not found
+
{t('errors.notFound')}
- Go to dashboard
+ {t('errors.goDashboard')}
@@ -57,25 +59,24 @@ function NotFound() {
// TanStack Router's catch-all for a render-time exception anywhere in the
// route tree — without this, an uncaught error just blanks the page.
function RouteError() {
+ const { t } = useLocale();
return (
- Something went wrong
+ {t('errors.somethingWrong')}
-
- Try reloading the page. If this keeps happening, let us know.
-
+
{t('errors.reloadHelp')}
- Go to dashboard
+ {t('errors.goDashboard')}
@@ -89,6 +90,15 @@ function AppToaster() {
}
function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function LocalizedDocument({ children }: { children: React.ReactNode }) {
+ const { locale } = useLocale();
// Registered manually with the plain browser API rather than a build
// plugin's virtual-module helper: this file is isomorphic (rendered for
// both the client and server Vite environments), and any such virtual
@@ -128,10 +138,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}, []);
return (
-
+
+
diff --git a/apps/web/src/routes/_authenticated/dashboard.tsx b/apps/web/src/routes/_authenticated/dashboard.tsx
index e97b01fe..4fc0ebd7 100644
--- a/apps/web/src/routes/_authenticated/dashboard.tsx
+++ b/apps/web/src/routes/_authenticated/dashboard.tsx
@@ -1,6 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import { queryOptions, useQuery } from '@tanstack/react-query';
import { gqlClient } from '#/graphql/client';
+import i18next from 'i18next';
import { ErrorState } from '#/components/ErrorState';
import {
AlertCircleIcon,
@@ -308,7 +309,9 @@ export function StatusBadge({ status }: { status: string }) {
- {status}
+ {i18next.t(`status.${status}`, {
+ defaultValue: status.replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()),
+ })}
);
}
diff --git a/apps/web/src/routes/_authenticated/route.tsx b/apps/web/src/routes/_authenticated/route.tsx
index 8555c465..15e26478 100644
--- a/apps/web/src/routes/_authenticated/route.tsx
+++ b/apps/web/src/routes/_authenticated/route.tsx
@@ -16,6 +16,7 @@ import { CommandPalette } from '#/components/CommandPalette';
import { ShortcutCheatSheet } from '#/components/ShortcutCheatSheet';
import { LogoMark } from '#/components/LogoMark';
import { NotificationInboxButton } from './-notification-inbox';
+import { useLocale } from '#/lib/i18n';
import {
BarChart2Icon,
BriefcaseIcon,
@@ -84,6 +85,7 @@ export const Route = createFileRoute('/_authenticated')({
});
export function AuthenticatedLayout() {
+ const { t } = useLocale();
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });
// Keyed by the immediate child route (dashboard/applications/settings/…)
@@ -118,6 +120,14 @@ export function AuthenticatedLayout() {
queryFn: () => gqlClient.request<{ me: { avatarUrl: string | null } | null }>(AVATAR_QUERY),
});
const avatarUrl = avatarData?.me?.avatarUrl;
+ const localizedMainNav = MAIN_NAV.map((item) => ({
+ ...item,
+ label: t(`nav.${item.label.toLowerCase()}`),
+ }));
+ const localizedSettingsNav = SETTINGS_NAV.map((item) => ({
+ ...item,
+ label: t(`settings.${item.label.toLowerCase()}`),
+ }));
const handleLogout = async () => {
await gqlClient.request(LOGOUT_MUTATION);
@@ -191,7 +201,7 @@ export function AuthenticatedLayout() {