From ebcec6f65da836979735b2fa7a621211f4e2a643 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Thu, 19 Feb 2026 12:29:03 +0500 Subject: [PATCH 1/5] type-safe i8n --- apps/web/.typesafe-i18n.json | 8 + apps/web/app/auth-demo/AuthDemoClient.tsx | 97 +- .../app/auth-demo/AuthDemoLoadingShell.tsx | 8 +- apps/web/app/components/LanguageSelector.tsx | 85 + apps/web/app/components/app-shell.tsx | 20 +- apps/web/app/components/auth-gate.tsx | 17 +- apps/web/app/components/nav-header.tsx | 15 +- apps/web/app/components/tenant-gate.tsx | 19 +- apps/web/app/components/tenant-switcher.tsx | 10 +- apps/web/app/crud-demo/page.tsx | 30 +- apps/web/app/forgot-password/page.tsx | 21 +- apps/web/app/global-crud-demo/page.tsx | 28 +- apps/web/app/hooks/useI18n.ts | 17 + apps/web/app/page.tsx | 53 +- apps/web/app/profile/page.tsx | 80 +- apps/web/app/providers/LocaleProvider.tsx | 75 + apps/web/app/reset-password/page.tsx | 31 +- apps/web/app/sign-in/page.tsx | 83 +- apps/web/app/sign-up/page.tsx | 99 +- apps/web/app/tenant-dashboard/page.tsx | 68 +- apps/web/app/tenant-members/page.tsx | 44 +- apps/web/app/verify-email/page.tsx | 16 +- apps/web/i18n/en/index.ts | 226 +++ apps/web/i18n/formatters.ts | 11 + apps/web/i18n/i18n-react.tsx | 16 + apps/web/i18n/i18n-types.ts | 1697 +++++++++++++++++ apps/web/i18n/i18n-util.async.ts | 27 + apps/web/i18n/i18n-util.sync.ts | 26 + apps/web/i18n/i18n-util.ts | 38 + apps/web/i18n/nl/index.ts | 226 +++ apps/web/package.json | 6 +- pnpm-lock.yaml | 13 + 32 files changed, 2840 insertions(+), 370 deletions(-) create mode 100644 apps/web/.typesafe-i18n.json create mode 100644 apps/web/app/components/LanguageSelector.tsx create mode 100644 apps/web/app/hooks/useI18n.ts create mode 100644 apps/web/app/providers/LocaleProvider.tsx create mode 100644 apps/web/i18n/en/index.ts create mode 100644 apps/web/i18n/formatters.ts create mode 100644 apps/web/i18n/i18n-react.tsx create mode 100644 apps/web/i18n/i18n-types.ts create mode 100644 apps/web/i18n/i18n-util.async.ts create mode 100644 apps/web/i18n/i18n-util.sync.ts create mode 100644 apps/web/i18n/i18n-util.ts create mode 100644 apps/web/i18n/nl/index.ts diff --git a/apps/web/.typesafe-i18n.json b/apps/web/.typesafe-i18n.json new file mode 100644 index 0000000..b8c57c8 --- /dev/null +++ b/apps/web/.typesafe-i18n.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json", + "baseLocale": "en", + "outputPath": "./i18n/", + "outputFormat": "TypeScript", + "generateOnlyTypes": false, + "adapter": "react" +} diff --git a/apps/web/app/auth-demo/AuthDemoClient.tsx b/apps/web/app/auth-demo/AuthDemoClient.tsx index b49d5d0..7eb2106 100644 --- a/apps/web/app/auth-demo/AuthDemoClient.tsx +++ b/apps/web/app/auth-demo/AuthDemoClient.tsx @@ -7,6 +7,7 @@ import { authClient as authClientDefault, useAuthClient, } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; import { AuthDemoLoadingShell } from "./AuthDemoLoadingShell"; type AuthStep = @@ -39,6 +40,7 @@ type AuthClientWithEmailOtp = typeof authClientDefault & { }; export default function AuthDemoClient() { + const { LL } = useI18n(); const authClient = useAuthClient(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; @@ -64,7 +66,7 @@ export default function AuthDemoClient() { if (signInResponse.error) { Logger.instance.critical("Error while Signing in", signInResponse.error); - setError("Failed to sign in with Google"); + setError(LL.Errors.failedSignInGoogle()); } }; @@ -96,12 +98,12 @@ export default function AuthDemoClient() { if (sendErr) { Logger.instance.critical(sendErr as Error); - setError("Failed to send OTP. Please try again."); + setError(LL.Errors.failedSendOtp()); return; } if (!success) { - setError("Unable to send OTP"); + setError(LL.Errors.unableToSendOtp()); return; } @@ -124,12 +126,12 @@ export default function AuthDemoClient() { if (verifyErr) { Logger.instance.critical(verifyErr as Error); - setError("Invalid OTP. Please try again."); + setError(LL.Errors.invalidOtp()); return; } if (!data) { - setError("Unable to verify OTP"); + setError(LL.Errors.unableToVerifyOtp()); } }; @@ -154,10 +156,10 @@ export default function AuthDemoClient() { }); setLoading(false); if (err) { - setError(err.message ?? "Sign up failed"); + setError(err.message ?? LL.Errors.signUpFailed()); return; } - if (!data) setError("Sign up failed"); + if (!data) setError(LL.Errors.signUpFailed()); else { await refetchSession(); } @@ -174,10 +176,10 @@ export default function AuthDemoClient() { }); setLoading(false); if (err) { - setError("Sign in failed"); + setError(LL.Errors.signInFailed()); return; } - if (!data) setError("Sign in failed"); + if (!data) setError(LL.Errors.signInFailed()); }; return ( @@ -200,16 +202,16 @@ export default function AuthDemoClient() { d="M15 19l-7-7 7-7" /> - Back to Home + {LL.Common.backToHome()}

- Better Auth Demo + {LL.Auth.betterAuthDemo()}

- {session ? "Welcome back!" : "Sign in to continue"} + {session ? LL.Auth.welcomeBack() : LL.Auth.signInToContinue()}

@@ -231,14 +233,14 @@ export default function AuthDemoClient() { /> - You are signed in + {LL.Auth.youAreSignedIn()}

- User Information + {LL.Auth.userInformation()}

{session.user.image && ( @@ -251,13 +253,13 @@ export default function AuthDemoClient() {
)}
-

Name

+

{LL.Forms.name()}

{session.user.name || "N/A"}

-

Email

+

{LL.Forms.email()}

{session.user.email}

@@ -279,7 +281,7 @@ export default function AuthDemoClient() { }} className="w-full px-4 py-3 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors" > - Sign Out + {LL.Auth.signOutButton()}
) : ( @@ -334,7 +336,7 @@ export default function AuthDemoClient() { d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> - Sign in with Google + {LL.Auth.signInWithGoogle()}
@@ -342,7 +344,9 @@ export default function AuthDemoClient() {
- Or + + {LL.Common.or()} +
@@ -364,7 +368,7 @@ export default function AuthDemoClient() { d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> - Sign in with Email OTP + {LL.Auth.signInWithEmailOtp()}
@@ -373,7 +377,7 @@ export default function AuthDemoClient() {
- Or Email & Password + {LL.Auth.orEmailPassword()}
@@ -389,7 +393,7 @@ export default function AuthDemoClient() { disabled={loading} className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-emerald-600 text-white font-medium rounded-lg hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - Sign up with Email & Password + {LL.Auth.signUpWithEmailPassword()} )} @@ -420,7 +424,7 @@ export default function AuthDemoClient() { htmlFor="signup-name" className="block text-sm font-medium text-gray-700 mb-2" > - Name + {LL.Forms.name()} setName(e.target.value)} required - placeholder="Your name" + placeholder={LL.Forms.namePlaceholder()} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" /> @@ -437,7 +441,7 @@ export default function AuthDemoClient() { htmlFor="signup-email" className="block text-sm font-medium text-gray-700 mb-2" > - Email + {LL.Forms.email()} setEmail(e.target.value)} required - placeholder="you@example.com" + placeholder={LL.Forms.emailPlaceholder()} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" /> @@ -454,7 +458,7 @@ export default function AuthDemoClient() { htmlFor="signup-password" className="block text-sm font-medium text-gray-700 mb-2" > - Password (min 8 characters) + {LL.Forms.passwordMinChars()} @@ -473,7 +477,7 @@ export default function AuthDemoClient() { disabled={loading} className="w-full px-4 py-3 bg-emerald-600 text-white font-medium rounded-lg hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - {loading ? "Creating account..." : "Sign up"} + {loading ? LL.Auth.creatingAccount() : LL.Auth.signUp()} )} @@ -499,7 +503,7 @@ export default function AuthDemoClient() { htmlFor="signin-email" className="block text-sm font-medium text-gray-700 mb-2" > - Email + {LL.Forms.email()} setEmail(e.target.value)} required - placeholder="you@example.com" + placeholder={LL.Forms.emailPlaceholder()} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> @@ -516,7 +520,7 @@ export default function AuthDemoClient() { htmlFor="signin-password" className="block text-sm font-medium text-gray-700 mb-2" > - Password + {LL.Forms.password()} setPassword(e.target.value)} required - placeholder="••••••••" + placeholder={LL.Forms.passwordPlaceholder()} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> @@ -533,7 +537,7 @@ export default function AuthDemoClient() { disabled={loading} className="w-full px-4 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - {loading ? "Signing in..." : "Sign in"} + {loading ? LL.Auth.signingIn() : LL.Auth.signIn()} )} @@ -559,7 +563,7 @@ export default function AuthDemoClient() { htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2" > - Email Address + {LL.Forms.emailAddress()} setEmail(e.target.value)} required - placeholder="Enter your email" + placeholder={LL.Forms.enterYourEmail()} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> @@ -577,7 +581,7 @@ export default function AuthDemoClient() { disabled={loading} className="w-full px-4 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - {loading ? "Sending..." : "Send OTP"} + {loading ? LL.Common.sending() : LL.Auth.sendOtp()} )} @@ -601,8 +605,7 @@ export default function AuthDemoClient() { >

- We sent a verification code to{" "} - {email} + {LL.Auth.otpSentTo({ email })}

@@ -611,7 +614,7 @@ export default function AuthDemoClient() { htmlFor="otp" className="block text-sm font-medium text-gray-700 mb-2" > - Verification Code + {LL.Forms.verificationCode()} setOtp(e.target.value)} required - placeholder="Enter 6-digit code" + placeholder={LL.Forms.verificationCodePlaceholder()} maxLength={6} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center text-2xl tracking-widest font-mono" /> @@ -630,7 +633,7 @@ export default function AuthDemoClient() { disabled={loading} className="w-full px-4 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - {loading ? "Verifying..." : "Verify OTP"} + {loading ? LL.Auth.verifying() : LL.Auth.verifyOtp()} )} diff --git a/apps/web/app/auth-demo/AuthDemoLoadingShell.tsx b/apps/web/app/auth-demo/AuthDemoLoadingShell.tsx index 199ae7f..d8e974e 100644 --- a/apps/web/app/auth-demo/AuthDemoLoadingShell.tsx +++ b/apps/web/app/auth-demo/AuthDemoLoadingShell.tsx @@ -1,8 +1,14 @@ +"use client"; + +import { useI18n } from "../hooks/useI18n"; + /** * Single loading shell for auth-demo. Used by the dynamic import and by * AuthDemoClient so server and client never render different markup (avoids hydration mismatch). */ export function AuthDemoLoadingShell() { + const { LL } = useI18n(); + return (
@@ -10,7 +16,7 @@ export function AuthDemoLoadingShell() { className="mx-auto h-12 w-12 animate-spin rounded-full border-2 border-b-gray-900 border-transparent" aria-hidden /> -

Loading...

+

{LL.Common.loading()}

); diff --git a/apps/web/app/components/LanguageSelector.tsx b/apps/web/app/components/LanguageSelector.tsx new file mode 100644 index 0000000..262aab9 --- /dev/null +++ b/apps/web/app/components/LanguageSelector.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useLocaleContext } from "../providers/LocaleProvider"; +import type { Locales } from "../../i18n/i18n-types"; +import { locales } from "../../i18n/i18n-util"; + +const localeLabels: Record = { + en: "English", + nl: "Nederlands", +}; + +export function LanguageSelector() { + const { locale, setLocale } = useLocaleContext(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ + + {open && ( +
+ {locales.map((l) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/components/app-shell.tsx b/apps/web/app/components/app-shell.tsx index c1c5359..9f87748 100644 --- a/apps/web/app/components/app-shell.tsx +++ b/apps/web/app/components/app-shell.tsx @@ -3,19 +3,17 @@ import { AuthGate } from "./auth-gate"; import { TenantGate } from "./tenant-gate"; import { NavHeader } from "./nav-header"; +import { LocaleProvider } from "../providers/LocaleProvider"; -/** - * Client-side shell that gates the entire app behind auth, - * then ensures a tenant is selected before rendering children. - * Also provides the shared nav header with the tenant switcher. - */ export function AppShell({ children }: { children: React.ReactNode }) { return ( - - - -
{children}
-
-
+ + + + +
{children}
+
+
+
); } diff --git a/apps/web/app/components/auth-gate.tsx b/apps/web/app/components/auth-gate.tsx index d60e157..881f727 100644 --- a/apps/web/app/components/auth-gate.tsx +++ b/apps/web/app/components/auth-gate.tsx @@ -2,6 +2,7 @@ import { usePathname } from "next/navigation"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; const PUBLIC_PATHS = [ "/auth-demo", @@ -12,12 +13,6 @@ const PUBLIC_PATHS = [ "/verify-email", ]; -/** - * Gate that blocks rendering of children until the user is authenticated. - * Routes in PUBLIC_PATHS are always accessible (e.g. the login page). - * When unauthenticated, shows a login prompt redirecting to /auth-demo. - * When auth state is still loading, shows a spinner. - */ export function AuthGate({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const authClient = useAuthClient(); @@ -51,6 +46,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) { } function AuthGateLoading() { + const { LL } = useI18n(); return (
@@ -58,28 +54,29 @@ function AuthGateLoading() { className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-b-blue-400 border-transparent" aria-hidden /> -

Loading...

+

{LL.Common.loading()}

); } function AuthGateLogin() { + const { LL } = useI18n(); return (
BE
-

Sign in required

+

{LL.Auth.signInRequired()}

- You need to be authenticated to access this application. + {LL.Auth.signInRequiredMessage()}

- Sign in + {LL.Auth.signIn()}
diff --git a/apps/web/app/components/nav-header.tsx b/apps/web/app/components/nav-header.tsx index 50caf8b..cd415cc 100644 --- a/apps/web/app/components/nav-header.tsx +++ b/apps/web/app/components/nav-header.tsx @@ -4,9 +4,12 @@ import { useState, useRef, useEffect } from "react"; import Link from "next/link"; import { trpc } from "@repo/trpc/client"; import { TenantSwitcher } from "./tenant-switcher"; +import { LanguageSelector } from "./LanguageSelector"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; export function NavHeader() { + const { LL } = useI18n(); const authClient = useAuthClient(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; @@ -68,13 +71,13 @@ export function NavHeader() {
BE
- Full Stack + {LL.Navigation.fullStack()}
+ - {/* User menu */}
@@ -197,7 +200,7 @@ export function NavHeader() { d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> - Sign out + {LL.Navigation.signOut()}
diff --git a/apps/web/app/components/tenant-gate.tsx b/apps/web/app/components/tenant-gate.tsx index 4f847b6..4577bfe 100644 --- a/apps/web/app/components/tenant-gate.tsx +++ b/apps/web/app/components/tenant-gate.tsx @@ -3,15 +3,10 @@ import { useEffect, useRef } from "react"; import { trpc } from "@repo/trpc/client"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; -/** - * After auth, loads the user's tenants via myTenants (auth-only tier — no tenant required). - * If the user has tenants but none selected, auto-selects the first one before rendering children. - * If the user has no tenants and is a Super Admin, lets them through (so they can create tenants). - * If the user has no tenants and is NOT a Super Admin, shows a message. - * If the user is NOT authenticated, passes through (AuthGate handles that). - */ export function TenantGate({ children }: { children: React.ReactNode }) { + const { LL } = useI18n(); const authClient = useAuthClient(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; @@ -65,7 +60,6 @@ export function TenantGate({ children }: { children: React.ReactNode }) { } }, [tenants, selectedTenantId, switchTenant]); - // Not authenticated — let AuthGate handle it; don't block on tenants. if (!isAuthenticated) { return <>{children}; } @@ -78,7 +72,7 @@ export function TenantGate({ children }: { children: React.ReactNode }) { className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-b-blue-400 border-transparent" aria-hidden /> -

Loading your tenants...

+

{LL.Errors.loadingYourTenants()}

); @@ -108,11 +102,10 @@ export function TenantGate({ children }: { children: React.ReactNode }) {

- No tenants assigned + {LL.Errors.noTenantsAssigned()}

- You don't have access to any tenants yet. Please contact an - administrator to get added to a tenant. + {LL.Errors.noTenantsMessage()}

@@ -128,7 +121,7 @@ export function TenantGate({ children }: { children: React.ReactNode }) { aria-hidden />

- Setting up your tenant... + {LL.Errors.settingUpTenant()}

diff --git a/apps/web/app/components/tenant-switcher.tsx b/apps/web/app/components/tenant-switcher.tsx index 455d57a..e152c36 100644 --- a/apps/web/app/components/tenant-switcher.tsx +++ b/apps/web/app/components/tenant-switcher.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { trpc } from "@repo/trpc/client"; +import { useI18n } from "../hooks/useI18n"; interface TenantInfo { id: string; @@ -11,6 +12,7 @@ interface TenantInfo { } export function TenantSwitcher() { + const { LL } = useI18n(); const utils = trpc.useUtils(); const myTenants = trpc.tenant.myTenants.useQuery(undefined, { refetchOnWindowFocus: false, @@ -79,8 +81,8 @@ export function TenantSwitcher() { {switchTenant.isPending - ? "Switching..." - : (currentTenant?.name ?? "Select tenant")} + ? LL.Navigation.switching() + : (currentTenant?.name ?? LL.Navigation.selectTenant())}

- Switch tenant + {LL.Navigation.switchTenant()}