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/I18N.md b/apps/web/I18N.md new file mode 100644 index 0000000..a9b61bb --- /dev/null +++ b/apps/web/I18N.md @@ -0,0 +1,22 @@ +# i18n Usage + +All translations live in `i18n/en/index.ts` (English) and `i18n/nl/index.ts` (Dutch), organized by namespace. + +```tsx +"use client"; +import { useI18n } from "../hooks/useI18n"; + +export function MyComponent() { + const { LL, locale, setLocale } = useI18n(); + return

{LL.Home.title()}

; +} +``` + +- **Add a key:** add it to both `i18n/en/index.ts` and `i18n/nl/index.ts` under the appropriate namespace. +- **Use a key:** call `LL.Namespace.key()` — always a function call, never a string lookup. +- **With params:** `LL.Dashboard.welcome({ name: "Ann" })` where the translation is `"Welcome, {name}"`. +- **Switch language:** `setLocale("nl")` — persists to `localStorage` automatically. +- **Regenerate types:** `pnpm --filter web i18n:generate` after changing translation structure. +- **Add a new locale:** create `i18n//index.ts` (e.g. `i18n/de/index.ts`), copy the `en` structure, translate the values, then run `pnpm --filter web i18n:generate` — the generator auto-updates types, loaders, and locale lists. + +**Do NOT edit** files marked `auto-generated by 'typesafe-i18n'` (`i18n-types.ts`, `i18n-util.ts`, `i18n-util.sync.ts`, `i18n-util.async.ts`, `i18n-react.tsx`). Only edit `en/index.ts`, `nl/index.ts` (and other locale files), and `formatters.ts`. 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..fd34ad5 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..f2975b6 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

-

- You need to be authenticated to access this application. -

+

+ {LL.Auth.signInRequired()} +

+

{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..e55f31f 100644 --- a/apps/web/app/components/nav-header.tsx +++ b/apps/web/app/components/nav-header.tsx @@ -4,9 +4,45 @@ 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; + const isAuthenticated = !!session?.user; + + return ( + + ); +} + +function AuthenticatedNav() { + const { LL } = useI18n(); const authClient = useAuthClient(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; @@ -58,33 +94,120 @@ export function NavHeader() { (isSuperAdmin || myTenants.some((t) => t.role === "TENANT_ADMIN")); return ( - + ); } diff --git a/apps/web/app/components/tenant-gate.tsx b/apps/web/app/components/tenant-gate.tsx index 4f847b6..0eedc71 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,9 @@ 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,12 +104,9 @@ 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..73a746e 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()}

    @@ -122,7 +124,9 @@ export function TenantSwitcher() { - {t.role === "TENANT_ADMIN" ? "Admin" : "User"} + {t.role === "TENANT_ADMIN" + ? LL.Common.roleAdmin() + : LL.Common.roleUser()} diff --git a/apps/web/app/crud-demo/page.tsx b/apps/web/app/crud-demo/page.tsx index 6f6aba0..b81d064 100644 --- a/apps/web/app/crud-demo/page.tsx +++ b/apps/web/app/crud-demo/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { trpc } from "@repo/trpc/client"; import Link from "next/link"; import { TenantDashboardOnly } from "../components/tenant-dashboard-only"; +import { useI18n } from "../hooks/useI18n"; type DbType = "mongoose" | "prisma"; @@ -13,6 +14,7 @@ interface CrudItem { } function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { + const { LL } = useI18n(); const utils = trpc.useUtils(); const [content, setContent] = useState(""); const [editingId, setEditingId] = useState(null); @@ -93,7 +95,7 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { if (crudList.isLoading) { return (
    -

    Loading items...

    +

    {LL.Common.loadingItems()}

    ); } @@ -103,8 +105,7 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) {

    - {crudList.data.cruds.length}{" "} - {crudList.data.cruds.length === 1 ? "Item" : "Items"} + {LL.Common.itemCount({ count: crudList.data.cruds.length })}

      @@ -171,9 +172,7 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { return (
      -

      - No items yet. Add one to get started! -

      +

      {LL.Common.noItemsYet()}

      ); }; @@ -201,7 +200,7 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) {
      setContent(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCreate()} @@ -212,7 +211,7 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { disabled={!content.trim() || createCrud.isPending} className={`bg-gradient-to-r ${buttonColors} disabled:from-slate-600 disabled:to-slate-600 disabled:cursor-not-allowed px-6 py-3 rounded-lg text-white font-semibold transition-all duration-200 shadow-lg`} > - {createCrud.isPending ? "Adding..." : "Add"} + {createCrud.isPending ? LL.Common.adding() : LL.Common.add()}
    @@ -223,7 +222,7 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { disabled={crudList.isRefetching} className={`bg-gradient-to-r ${buttonColors} disabled:from-slate-600 disabled:to-slate-600 disabled:cursor-not-allowed px-4 py-2 rounded-lg text-white font-semibold transition-colors`} > - {crudList.isRefetching ? "Refreshing..." : "Refresh"} + {crudList.isRefetching ? LL.Common.refreshing() : LL.Common.refresh()} @@ -235,6 +234,8 @@ function CrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { } export default function CrudDemo() { + const { LL } = useI18n(); + return (
    @@ -255,26 +256,25 @@ export default function CrudDemo() { d="M15 19l-7-7 7-7" /> - Back to Home + {LL.Common.backToHome()}

    - Dual Database CRUD Demo + {LL.Dashboard.dualDatabaseCrudDemo()}

    - Side-by-side comparison of Mongoose (MongoDB) and Prisma - (PostgreSQL) + {LL.Dashboard.crudDemoSubtitle()}

    - NextJs (TailwindCSS) • NestJs • tRPC • Transactions + {LL.Dashboard.crudDemoTechStack()}

    - → Tenant Dashboard (super-admin) + {LL.Dashboard.tenantDashboardLink()}
    diff --git a/apps/web/app/forgot-password/page.tsx b/apps/web/app/forgot-password/page.tsx index e45ddb2..d178a6c 100644 --- a/apps/web/app/forgot-password/page.tsx +++ b/apps/web/app/forgot-password/page.tsx @@ -4,8 +4,10 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; export default function ForgotPasswordPage() { + const { LL } = useI18n(); const authClient = useAuthClient(); const router = useRouter(); const sessionResult = authClient?.useSession?.(); @@ -47,12 +49,12 @@ export default function ForgotPasswordPage() {

    - Forgot Password + {LL.Auth.forgotPasswordTitle()}

    {submitted - ? "Check your inbox" - : "Enter your email to receive a password reset link"} + ? LL.Auth.checkYourInbox() + : LL.Auth.forgotPasswordSubtitle()}

    @@ -74,14 +76,13 @@ export default function ForgotPasswordPage() {

    - If an account exists with this email, you'll receive a link - to set your password. + {LL.Auth.resetLinkSentMessage()}

    - Back to Sign In + {LL.Auth.backToSignIn()}
    ) : ( @@ -97,7 +98,7 @@ export default function ForgotPasswordPage() { htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2" > - Email Address + {LL.Forms.emailAddress()} 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" /> @@ -114,14 +115,14 @@ export default function ForgotPasswordPage() { 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 Reset Link"} + {loading ? LL.Common.sending() : LL.Auth.sendResetLink()}

    - Back to Sign In + {LL.Auth.backToSignIn()}

    diff --git a/apps/web/app/global-crud-demo/page.tsx b/apps/web/app/global-crud-demo/page.tsx index 96b549d..70e0eca 100644 --- a/apps/web/app/global-crud-demo/page.tsx +++ b/apps/web/app/global-crud-demo/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { trpc } from "@repo/trpc/client"; import Link from "next/link"; +import { useI18n } from "../hooks/useI18n"; type DbType = "mongoose" | "prisma"; @@ -12,6 +13,7 @@ interface GlobalCrudItem { } function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { + const { LL } = useI18n(); const utils = trpc.useUtils(); const [content, setContent] = useState(""); const [editingId, setEditingId] = useState(null); @@ -89,7 +91,7 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { if (list.isLoading) { return (
    -

    Loading items...

    +

    {LL.Common.loadingItems()}

    ); } @@ -99,8 +101,7 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) {

    - {list.data.items.length}{" "} - {list.data.items.length === 1 ? "Item" : "Items"} (global) + {LL.Common.itemCountGlobal({ count: list.data.items.length })}

      @@ -167,9 +168,7 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { return (
      -

      - No items yet. Add one to get started! -

      +

      {LL.Common.noItemsYet()}

      ); }; @@ -197,7 +196,7 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) {
      setContent(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCreate()} @@ -208,7 +207,7 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { disabled={!content.trim() || createItem.isPending} className={`bg-gradient-to-r ${buttonColors} disabled:from-slate-600 disabled:to-slate-600 disabled:cursor-not-allowed px-6 py-3 rounded-lg text-white font-semibold transition-all duration-200 shadow-lg`} > - {createItem.isPending ? "Adding..." : "Add"} + {createItem.isPending ? LL.Common.adding() : LL.Common.add()}
    @@ -219,7 +218,7 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { disabled={list.isRefetching} className={`bg-gradient-to-r ${buttonColors} disabled:from-slate-600 disabled:to-slate-600 disabled:cursor-not-allowed px-4 py-2 rounded-lg text-white font-semibold transition-colors`} > - {list.isRefetching ? "Refreshing..." : "Refresh"} + {list.isRefetching ? LL.Common.refreshing() : LL.Common.refresh()} @@ -231,6 +230,8 @@ function GlobalCrudPanel({ dbType }: Readonly<{ dbType: DbType }>) { } export default function GlobalCrudDemo() { + const { LL } = useI18n(); + return (
    @@ -251,19 +252,18 @@ export default function GlobalCrudDemo() { d="M15 19l-7-7 7-7" /> - Back to Home + {LL.Common.backToHome()}

    - Global CRUD Demo + {LL.Dashboard.globalCrudDemoTitle()}

    - Same as CRUD but shared across all tenants. Everyone sees and edits - the same data. + {LL.Dashboard.globalCrudDemoSubtitle()}

    - Mongoose (MongoDB) & Prisma (PostgreSQL) • No tenant scope + {LL.Dashboard.globalCrudDemoTechStack()}

    diff --git a/apps/web/app/hooks/useI18n.ts b/apps/web/app/hooks/useI18n.ts new file mode 100644 index 0000000..96ca004 --- /dev/null +++ b/apps/web/app/hooks/useI18n.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useLocaleContext } from "../providers/LocaleProvider"; +import type { Locales, TranslationFunctions } from "../../i18n/i18n-types"; + +interface UseI18nReturn { + LL: TranslationFunctions; + locale: Locales; + setLocale: (l: Locales) => void; +} + +export function useI18n(): UseI18nReturn { + const { LL, locale, setLocale } = useLocaleContext(); + return { LL, locale, setLocale }; +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7499b00..57b463e 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,8 +1,13 @@ +"use client"; + import Link from "next/link"; import { TenantDashboardOnly } from "./components/tenant-dashboard-only"; import { TenantAdminOnly } from "./components/tenant-admin-only"; +import { useI18n } from "./hooks/useI18n"; export default function Home() { + const { LL } = useI18n(); + return (
    @@ -12,12 +17,10 @@ export default function Home() { BE

    - Full Stack Boilerplate + {LL.Home.title()}

    -

    - NextJs (Tailwind CSS), NestJs, Expo, tRPC, Better Auth -

    +

    {LL.Home.subtitle()}

    @@ -39,14 +42,15 @@ export default function Home() { />
    -

    CRUD Demo

    +

    + {LL.Home.crudDemoTitle()} +

    - Test Create, Read, Update, Delete operations with tRPC and - Prisma integration. Full-stack type safety demonstration. + {LL.Home.crudDemoDescription()}

    - Try it out + {LL.Home.tryItOut()}

    - Global CRUD Demo + {LL.Home.globalCrudDemoTitle()}

    - Same as CRUD but shared across all tenants. Everyone sees and - edits the same data. + {LL.Home.globalCrudDemoDescription()}

    - Try it out + {LL.Home.tryItOut()}

    - Platform Tenants + {LL.Home.platformTenantsTitle()}

    - Create, edit, and remove tenants across the platform. - Super-admin only. + {LL.Home.platformTenantsDescription()}

    - Manage tenants + {LL.Home.manageTenants()}

    - Manage Members + {LL.Home.manageMembersTitle()}

    - Add or remove members from tenants you administer. + {LL.Home.manageMembersDescription()}

    - Manage members + {LL.Home.manageMembers()}
    -

    Auth Demo

    +

    + {LL.Home.authDemoTitle()} +

    - Test authentication with Better Auth. OAuth integration with - Google, session management, and protected routes. + {LL.Home.authDemoDescription()}

    - Try it out + {LL.Home.tryItOut()}
    -

    - Built with modern tools for rapid development -

    +

    {LL.Home.footer()}

    diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index 210b20b..f2f1961 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; interface AccountInfo { id: string; @@ -11,6 +12,7 @@ interface AccountInfo { } export default function ProfilePage() { + const { LL } = useI18n(); const authClient = useAuthClient(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; @@ -82,13 +84,16 @@ export default function ProfilePage() { if (!authClient) return; if (newPassword !== confirmPassword) { - setPasswordMessage({ type: "error", text: "Passwords do not match." }); + setPasswordMessage({ + type: "error", + text: LL.Errors.passwordsDoNotMatch(), + }); return; } if (newPassword.length < 8) { setPasswordMessage({ type: "error", - text: "Password must be at least 8 characters.", + text: LL.Errors.passwordMinLength(), }); return; } @@ -107,14 +112,14 @@ export default function ProfilePage() { if (error) { setPasswordMessage({ type: "error", - text: error.message ?? "Failed to change password.", + text: error.message ?? LL.Errors.failedChangePassword(), }); return; } setPasswordMessage({ type: "success", - text: "Password changed successfully.", + text: LL.Errors.passwordChangedSuccess(), }); setCurrentPassword(""); setNewPassword(""); @@ -176,13 +181,13 @@ export default function ProfilePage() { d="M15 19l-7-7 7-7" /> - Back to Home + {LL.Common.backToHome()} -

    Profile

    -

    - Manage your account security and linked sign-in methods. -

    +

    + {LL.Settings.profileTitle()} +

    +

    {LL.Settings.profileSubtitle()}

    {/* Email verification warning */} {!user.emailVerified && ( @@ -203,14 +208,14 @@ export default function ProfilePage() {

    - Your email is not verified + {LL.Settings.emailNotVerified()}

    - Please verify your email to access all features. + {LL.Settings.verifyEmailPrompt()}

    {verificationSent ? (

    - Verification email sent! Check your inbox. + {LL.Auth.verificationEmailSent()}

    ) : ( )}
    @@ -231,14 +236,14 @@ export default function ProfilePage() { {/* User info */}

    - Account Info + {LL.Settings.accountInfo()}

    {user.image && (
    Profile
    @@ -250,13 +255,17 @@ export default function ProfilePage() { {!user.image && ( <>
    - Name + + {LL.Forms.name()} + {user.name || "—"}
    - Email + + {LL.Forms.email()} + {user.email}
    @@ -267,13 +276,13 @@ export default function ProfilePage() { {/* Linked Accounts */}

    - Linked Accounts + {LL.Settings.linkedAccounts()}

    {loadingAccounts ? (
    - Loading... + {LL.Common.loading()}
    ) : (
    @@ -301,23 +310,27 @@ export default function ProfilePage() {
    -

    Google

    +

    + {LL.Settings.google()} +

    {hasGoogle && ( -

    Connected

    +

    + {LL.Settings.connected()} +

    )}
    {hasGoogle ? ( - Connected + {LL.Settings.connected()} ) : ( )}
    @@ -342,21 +355,23 @@ export default function ProfilePage() {

    - Email & Password + {LL.Settings.emailAndPassword()}

    {hasCredential && ( -

    Password set

    +

    + {LL.Settings.passwordSet()} +

    )}
    {hasCredential ? ( - Connected + {LL.Settings.connected()} ) : ( - Not set + {LL.Settings.notSet()} )}
    @@ -366,7 +381,9 @@ export default function ProfilePage() { {/* Password Management */}
    -

    Password

    +

    + {LL.Settings.passwordTitle()} +

    {passwordMessage && (
    - Change Password + {LL.Settings.changePassword()} ) : (
    - Current Password + {LL.Settings.currentPassword()} setCurrentPassword(e.target.value)} required className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" - placeholder="••••••••" + placeholder={LL.Forms.passwordPlaceholder()} />
    @@ -422,7 +439,7 @@ export default function ProfilePage() { htmlFor="new-pw" className="block text-sm text-slate-400 mb-1" > - New Password (min 8 characters) + {LL.Settings.newPasswordLabel()}
    @@ -441,7 +458,7 @@ export default function ProfilePage() { htmlFor="confirm-pw" className="block text-sm text-slate-400 mb-1" > - Confirm New Password + {LL.Settings.confirmNewPassword()}
    @@ -461,7 +478,9 @@ export default function ProfilePage() { disabled={passwordLoading} className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50" > - {passwordLoading ? "Saving..." : "Save Password"} + {passwordLoading + ? LL.Common.saving() + : LL.Settings.savePassword()}
    @@ -483,11 +502,11 @@ export default function ProfilePage() { ) : (

    - Add a password to sign in without Google or OTP. + {LL.Settings.addPasswordDescription()}

    {setPasswordSent ? (

    - Check your inbox to set your password. + {LL.Settings.checkInboxForPassword()}

    ) : ( )}
    diff --git a/apps/web/app/providers/LocaleProvider.tsx b/apps/web/app/providers/LocaleProvider.tsx new file mode 100644 index 0000000..c4c0710 --- /dev/null +++ b/apps/web/app/providers/LocaleProvider.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import type { Locales, TranslationFunctions } from "../../i18n/i18n-types"; +import { + baseLocale, + i18nObject, + isLocale, + loadedLocales, +} from "../../i18n/i18n-util"; +import { loadLocaleAsync } from "../../i18n/i18n-util.async"; + +const STORAGE_KEY = "app-locale"; + +interface LocaleContextValue { + locale: Locales; + setLocale: (l: Locales) => void; + LL: TranslationFunctions; +} + +const LocaleContext = createContext(null); + +export function useLocaleContext(): LocaleContextValue { + const ctx = useContext(LocaleContext); + if (!ctx) + throw new Error("useLocaleContext must be used within LocaleProvider"); + return ctx; +} + +function readStoredLocale(): Locales { + if (typeof window === "undefined") return baseLocale; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && isLocale(stored)) return stored; + } catch { + // localStorage unavailable + } + return baseLocale; +} + +export function LocaleProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(baseLocale); + const [ready, setReady] = useState(false); + + useEffect(() => { + const initial = readStoredLocale(); + void loadLocaleAsync(initial).then(() => { + setLocaleState(initial); + setReady(true); + }); + }, []); + + const setLocale = useCallback((newLocale: Locales) => { + void loadLocaleAsync(newLocale).then(() => { + setLocaleState(newLocale); + try { + localStorage.setItem(STORAGE_KEY, newLocale); + } catch { + // localStorage unavailable + } + }); + }, []); + + const LL = useMemo(() => { + if (!ready || !loadedLocales[locale]) return null; + return i18nObject(locale); + }, [locale, ready]); + + if (!LL) { + return null; + } + + return ( + + {children} + + ); +} diff --git a/apps/web/app/reset-password/page.tsx b/apps/web/app/reset-password/page.tsx index d3e77a0..7b60c44 100644 --- a/apps/web/app/reset-password/page.tsx +++ b/apps/web/app/reset-password/page.tsx @@ -4,8 +4,10 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; function ResetPasswordContent() { + const { LL } = useI18n(); const authClient = useAuthClient(); const router = useRouter(); const searchParams = useSearchParams(); @@ -45,17 +47,16 @@ function ResetPasswordContent() {

    - Invalid or Expired Link + {LL.Auth.invalidOrExpiredLink()}

    - This password reset link is invalid or has expired. Please request a - new one. + {LL.Auth.invalidOrExpiredLinkMessage()}

    - Request New Link + {LL.Auth.requestNewLink()}
    @@ -67,12 +68,12 @@ function ResetPasswordContent() { if (!authClient) return; if (newPassword !== confirmPassword) { - setError("Passwords do not match."); + setError(LL.Errors.passwordsDoNotMatch()); return; } if (newPassword.length < 8) { - setError("Password must be at least 8 characters."); + setError(LL.Errors.passwordMinLength()); return; } @@ -93,14 +94,13 @@ function ResetPasswordContent() { ) { setTokenError(true); } else { - setError(err.message ?? "Failed to reset password. Please try again."); + setError(err.message ?? LL.Errors.failedResetPassword()); } return; } router.push( - "/sign-in?success=" + - encodeURIComponent("Password set successfully. You can now sign in."), + "/sign-in?success=" + encodeURIComponent(LL.Auth.passwordSetSuccess()), ); }; @@ -110,11 +110,9 @@ function ResetPasswordContent() {

    - Set Your Password + {LL.Auth.setYourPassword()}

    -

    - Enter a new password for your account -

    +

    {LL.Auth.setPasswordSubtitle()}

    {error && ( @@ -150,7 +148,7 @@ function ResetPasswordContent() { htmlFor="new-password" className="block text-sm font-medium text-gray-700 mb-2" > - New Password (min 8 characters) + {LL.Settings.newPasswordLabel()}
    @@ -169,7 +167,7 @@ function ResetPasswordContent() { htmlFor="confirm-password" className="block text-sm font-medium text-gray-700 mb-2" > - Confirm Password + {LL.Forms.confirmPassword()} @@ -188,7 +186,7 @@ function ResetPasswordContent() { 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 ? "Setting password..." : "Set Password"} + {loading ? LL.Auth.settingPassword() : LL.Auth.setPassword()} diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx index 9a5979c..77fb036 100644 --- a/apps/web/app/sign-in/page.tsx +++ b/apps/web/app/sign-in/page.tsx @@ -8,6 +8,7 @@ import { authClient as authClientDefault, useAuthClient, } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; type AuthView = "choose" | "email-otp" | "otp-verify" | "email-password"; @@ -27,6 +28,7 @@ type AuthClientWithEmailOtp = typeof authClientDefault & { }; function SignInContent() { + const { LL } = useI18n(); const authClient = useAuthClient(); const router = useRouter(); const searchParams = useSearchParams(); @@ -63,7 +65,7 @@ function SignInContent() { }); if (result.error) { Logger.instance.critical("Error while signing in", result.error); - setError("Failed to sign in with Google"); + setError(LL.Errors.failedSignInGoogle()); } }; @@ -80,11 +82,11 @@ function SignInContent() { setLoading(false); if (result.error) { - setError("Failed to send OTP. Please try again."); + setError(LL.Errors.failedSendOtp()); return; } if (!result.data) { - setError("Unable to send OTP"); + setError(LL.Errors.unableToSendOtp()); return; } setView("otp-verify"); @@ -103,11 +105,11 @@ function SignInContent() { setLoading(false); if (result.error) { - setError("Invalid OTP. Please try again."); + setError(LL.Errors.invalidOtp()); return; } if (!result.data) { - setError("Unable to verify OTP"); + setError(LL.Errors.unableToVerifyOtp()); return; } router.push("/"); @@ -133,7 +135,7 @@ function SignInContent() { if (ctx.error.status === 403) { handledByOnError = true; setShowUnverifiedError(true); - setError("Please verify your email address before signing in."); + setError(LL.Errors.verifyEmailBeforeSignIn()); } }, }, @@ -148,11 +150,9 @@ function SignInContent() { msg.includes("invalid credentials") || msg.includes("no password") ) { - setError( - "This account was created with Google or OTP. Use 'Forgot Password' below to set a password.", - ); + setError(LL.Errors.accountCreatedWithSocial()); } else { - setError("Invalid email or password."); + setError(LL.Errors.invalidEmailOrPassword()); } } }; @@ -165,7 +165,7 @@ function SignInContent() { callbackURL: `${typeof window !== "undefined" ? window.location.origin : ""}/verify-email`, }); setResendingVerification(false); - setError("Verification email sent! Check your inbox."); + setError(LL.Auth.verificationEmailSent()); setShowUnverifiedError(false); }; @@ -183,10 +183,10 @@ function SignInContent() {
    -

    Sign in

    -

    - Choose your preferred sign-in method -

    +

    + {LL.Auth.signInTitle()} +

    +

    {LL.Auth.signInSubtitle()}

    {successMessage && ( @@ -235,8 +235,8 @@ function SignInContent() { className="mt-2 block text-sm font-medium text-red-700 hover:text-red-800 underline disabled:opacity-50" > {resendingVerification - ? "Sending..." - : "Resend verification email"} + ? LL.Common.sending() + : LL.Auth.resendVerificationEmail()} )}
    @@ -269,7 +269,7 @@ function SignInContent() { 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()}
    @@ -277,7 +277,9 @@ function SignInContent() {
    - Or + + {LL.Common.or()} +
    @@ -302,7 +304,7 @@ function SignInContent() { 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()}
    @@ -311,7 +313,7 @@ function SignInContent() {
    - Or with Email & Password + {LL.Auth.orWithEmailPassword()}
    @@ -337,7 +339,7 @@ function SignInContent() { d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> - Sign in with Email & Password + {LL.Auth.signInWithEmailPassword()} )} @@ -355,7 +357,7 @@ function SignInContent() { htmlFor="otp-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" /> @@ -372,14 +374,14 @@ function SignInContent() { 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()} )} @@ -394,8 +396,7 @@ function SignInContent() { >

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

    @@ -403,7 +404,7 @@ function SignInContent() { htmlFor="otp-code" 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" /> @@ -421,7 +422,7 @@ function SignInContent() { 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()} )} @@ -450,7 +451,7 @@ function SignInContent() { 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" />
    @@ -467,7 +468,7 @@ function SignInContent() { 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" />
    @@ -483,7 +484,7 @@ function SignInContent() { href="/forgot-password" className="text-sm text-blue-600 hover:text-blue-700 font-medium" > - Forgot Password? + {LL.Auth.forgotPassword()}
    @@ -492,25 +493,25 @@ function SignInContent() { 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()} )}

    - Don't have an account?{" "} + {LL.Auth.dontHaveAccount()}{" "} - Sign up + {LL.Auth.signUp()}

    diff --git a/apps/web/app/sign-up/page.tsx b/apps/web/app/sign-up/page.tsx index 028bdc9..4d6ac68 100644 --- a/apps/web/app/sign-up/page.tsx +++ b/apps/web/app/sign-up/page.tsx @@ -4,41 +4,50 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; +import type { TranslationFunctions } from "../../i18n/i18n-types"; const NAME_REGEX = /^[a-zA-Z\s'-]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; -function validateName(value: string, label: string): string | null { +function validateName( + value: string, + label: string, + E: TranslationFunctions["Errors"], +): string | null { const trimmed = value.trim(); - if (!trimmed) return `${label} is required.`; - if (trimmed.length < 2) return `${label} must be at least 2 characters.`; - if (!NAME_REGEX.test(trimmed)) - return `${label} can only contain letters, spaces, hyphens, and apostrophes.`; + if (!trimmed) return E.nameRequired({ label }); + if (trimmed.length < 2) return E.nameMinLength({ label }); + if (!NAME_REGEX.test(trimmed)) return E.nameInvalidChars({ label }); return null; } -function validateEmail(value: string): string | null { +function validateEmail( + value: string, + E: TranslationFunctions["Errors"], +): string | null { const trimmed = value.trim(); - if (!trimmed) return "Email is required."; - if (!EMAIL_REGEX.test(trimmed)) return "Please enter a valid email address."; + if (!trimmed) return E.emailRequired(); + if (!EMAIL_REGEX.test(trimmed)) return E.emailInvalid(); return null; } -function validatePassword(value: string): string | null { - if (!value) return "Password is required."; - if (value.length < 8) return "Password must be at least 8 characters."; - if (value.length > 128) return "Password must be at most 128 characters."; - if (!/[A-Z]/.test(value)) - return "Password must contain at least one uppercase letter."; - if (!/[a-z]/.test(value)) - return "Password must contain at least one lowercase letter."; - if (!/[0-9]/.test(value)) return "Password must contain at least one number."; - if (!/[^A-Za-z0-9]/.test(value)) - return "Password must contain at least one special character."; +function validatePassword( + value: string, + E: TranslationFunctions["Errors"], +): string | null { + if (!value) return E.passwordRequired(); + if (value.length < 8) return E.passwordMinLength(); + if (value.length > 128) return E.passwordMaxLength(); + if (!/[A-Z]/.test(value)) return E.passwordUppercase(); + if (!/[a-z]/.test(value)) return E.passwordLowercase(); + if (!/[0-9]/.test(value)) return E.passwordNumber(); + if (!/[^A-Za-z0-9]/.test(value)) return E.passwordSpecialChar(); return null; } export default function SignUpPage() { + const { LL } = useI18n(); const authClient = useAuthClient(); const router = useRouter(); const sessionResult = authClient?.useSession?.(); @@ -70,17 +79,18 @@ export default function SignUpPage() { const validate = (): boolean => { const errors: Record = {}; + const E = LL.Errors; - const firstErr = validateName(firstName, "First name"); + const firstErr = validateName(firstName, LL.Forms.firstName(), E); if (firstErr) errors.firstName = firstErr; - const lastErr = validateName(lastName, "Last name"); + const lastErr = validateName(lastName, LL.Forms.lastName(), E); if (lastErr) errors.lastName = lastErr; - const emailErr = validateEmail(email); + const emailErr = validateEmail(email, E); if (emailErr) errors.email = emailErr; - const pwErr = validatePassword(password); + const pwErr = validatePassword(password, E); if (pwErr) errors.password = pwErr; setFieldErrors(errors); @@ -117,11 +127,9 @@ export default function SignUpPage() { err.message?.toLowerCase().includes("user already exists") || err.message?.toLowerCase().includes("already in use") ) { - setError( - "An account with this email already exists. Try signing in instead — if you used Google or OTP, you can set a password from your profile.", - ); + setError(LL.Errors.accountAlreadyExists()); } else { - setError(err.message ?? "Sign up failed. Please try again."); + setError(err.message ?? LL.Errors.signUpFailed()); } return; } @@ -149,19 +157,16 @@ export default function SignUpPage() {

    - Check your inbox + {LL.Auth.checkYourInbox()}

    - We've sent a verification email to{" "} - {email}. Please - check your inbox and click the link to verify your account before - signing in. + {LL.Auth.checkInboxMessage({ email })}

    - Go to Sign In + {LL.Auth.goToSignIn()} @@ -174,11 +179,9 @@ export default function SignUpPage() {

    - Create an account + {LL.Auth.signUpTitle()}

    -

    - Sign up with your email and password -

    +

    {LL.Auth.signUpSubtitle()}

    {error && ( @@ -215,7 +218,7 @@ export default function SignUpPage() { htmlFor="first-name" className="block text-sm font-medium text-gray-700 mb-2" > - First Name + {LL.Forms.firstName()} {fieldErrors.firstName && ( @@ -240,7 +243,7 @@ export default function SignUpPage() { htmlFor="last-name" className="block text-sm font-medium text-gray-700 mb-2" > - Last Name + {LL.Forms.lastName()} {fieldErrors.lastName && ( @@ -266,7 +269,7 @@ export default function SignUpPage() { htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2" > - Email + {LL.Forms.email()} {fieldErrors.email && ( @@ -289,7 +292,7 @@ export default function SignUpPage() { htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2" > - Password + {LL.Forms.password()} {fieldErrors.password ? ( @@ -310,8 +313,7 @@ export default function SignUpPage() {

    ) : (

    - Min 8 characters with uppercase, lowercase, number, and - special character. + {LL.Auth.passwordMinChars()}

    )}
    @@ -320,17 +322,17 @@ export default function SignUpPage() { 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()}

    - Already have an account?{" "} + {LL.Auth.alreadyHaveAccount()}{" "} - Sign in + {LL.Auth.signIn()}

    diff --git a/apps/web/app/tenant-dashboard/page.tsx b/apps/web/app/tenant-dashboard/page.tsx index e410ee2..a98984c 100644 --- a/apps/web/app/tenant-dashboard/page.tsx +++ b/apps/web/app/tenant-dashboard/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { trpc } from "@repo/trpc/client"; import Link from "next/link"; +import { useI18n } from "../hooks/useI18n"; interface TenantItem { id: string; @@ -11,6 +12,7 @@ interface TenantItem { } export default function TenantDashboard() { + const { LL } = useI18n(); const utils = trpc.useUtils(); const [name, setName] = useState(""); const [slug, setSlug] = useState(""); @@ -57,17 +59,15 @@ export default function TenantDashboard() { const trimmedName = name.trim(); const trimmedSlug = slug.trim().toLowerCase().replace(/\s+/g, "-"); if (!trimmedName) { - setErrorMessage("Name is required"); + setErrorMessage(LL.Errors.nameFieldRequired()); return; } if (!trimmedSlug) { - setErrorMessage("Slug is required"); + setErrorMessage(LL.Errors.slugRequired()); return; } if (!/^[a-z0-9-]+$/.test(trimmedSlug)) { - setErrorMessage( - "Slug must be lowercase letters, numbers, and hyphens only", - ); + setErrorMessage(LL.Errors.slugInvalid()); return; } createTenant.mutate({ @@ -94,17 +94,15 @@ export default function TenantDashboard() { const trimmedName = editName.trim(); const trimmedSlug = editSlug.trim().toLowerCase().replace(/\s+/g, "-"); if (!trimmedName) { - setErrorMessage("Name is required"); + setErrorMessage(LL.Errors.nameFieldRequired()); return; } if (!trimmedSlug) { - setErrorMessage("Slug is required"); + setErrorMessage(LL.Errors.slugRequired()); return; } if (!/^[a-z0-9-]+$/.test(trimmedSlug)) { - setErrorMessage( - "Slug must be lowercase letters, numbers, and hyphens only", - ); + setErrorMessage(LL.Errors.slugInvalid()); return; } updateTenant.mutate({ @@ -115,11 +113,7 @@ export default function TenantDashboard() { }; const handleDelete = (id: string, slug: string) => { - if ( - !globalThis.window?.confirm( - `Delete tenant "${slug}"? This cannot be undone.`, - ) - ) + if (!globalThis.window?.confirm(LL.Dashboard.deleteConfirm({ slug }))) return; deleteTenant.mutate({ id }); }; @@ -151,28 +145,27 @@ export default function TenantDashboard() { d="M15 19l-7-7 7-7" /> - Back to Home + {LL.Common.backToHome()}

    - Platform Tenants + {LL.Dashboard.platformTenantsTitle()}

    - Create, edit, and remove tenants. Super-admin only. + {LL.Dashboard.platformTenantsSubtitle()}

    {isUnauth && (
    - You must be logged in to access this page. + {LL.Dashboard.mustBeLoggedIn()}
    )} {isForbidden && (
    - Super-admin access required. Your email must be in - SUPER_ADMIN_EMAILS. + {LL.Dashboard.superAdminRequired()}
    )} @@ -193,28 +186,28 @@ export default function TenantDashboard() { {/* Add form */}

    - Add tenant + {LL.Dashboard.addTenant()}

    setName(e.target.value)} />
    setSlug( @@ -231,7 +224,9 @@ export default function TenantDashboard() { } className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 disabled:from-slate-600 disabled:to-slate-600 disabled:cursor-not-allowed px-6 py-2 rounded-lg text-white font-semibold transition-all" > - {createTenant.isPending ? "Adding..." : "Add tenant"} + {createTenant.isPending + ? LL.Common.adding() + : LL.Dashboard.addTenant()}
    @@ -240,20 +235,19 @@ export default function TenantDashboard() {

    - {tenants.length} tenant - {tenants.length !== 1 ? "s" : ""} + {LL.Dashboard.tenantCount({ count: tenants.length })}

    {tenantList.isLoading && (
    - Loading tenants... + {LL.Dashboard.loadingTenants()}
    )} {tenants.length === 0 && !tenantList.isLoading && (
    - No tenants yet. Add one above. + {LL.Dashboard.noTenantsYet()}
    )} @@ -269,7 +263,7 @@ export default function TenantDashboard() {
    - {updateTenant.isPending ? "Saving..." : "Save"} + {updateTenant.isPending + ? LL.Common.saving() + : LL.Common.save()}
    @@ -326,7 +322,7 @@ export default function TenantDashboard() {
    diff --git a/apps/web/app/tenant-members/page.tsx b/apps/web/app/tenant-members/page.tsx index 070c33a..60d9ca7 100644 --- a/apps/web/app/tenant-members/page.tsx +++ b/apps/web/app/tenant-members/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { trpc } from "@repo/trpc/client"; import Link from "next/link"; import { useAuthClient } from "../lib/auth/auth-client"; +import { useI18n } from "../hooks/useI18n"; interface TenantInfo { id: string; @@ -20,6 +21,7 @@ interface MemberItem { } export default function TenantMembers() { + const { LL } = useI18n(); const authClient = useAuthClient(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; @@ -111,7 +113,7 @@ export default function TenantMembers() { 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()}

    ); @@ -122,16 +124,16 @@ export default function TenantMembers() {

    - No admin access + {LL.Settings.noAdminAccess()}

    - You don't have admin access to any tenants. + {LL.Settings.noAdminAccessMessage()}

    - Back to Home + {LL.Common.backToHome()}
    @@ -158,15 +160,15 @@ export default function TenantMembers() { d="M15 19l-7-7 7-7" /> - Back to Home + {LL.Common.backToHome()}

    - Manage Members + {LL.Settings.manageMembersTitle()}

    - Add or remove members from your tenants. + {LL.Settings.manageMembersSubtitle()}

    @@ -186,7 +188,7 @@ export default function TenantMembers() {

    - Your tenants ({adminTenants.length}) + {LL.Settings.yourTenants({ count: adminTenants.length })}

      @@ -206,7 +208,9 @@ export default function TenantMembers() { > {t.name} - {t.role === "TENANT_ADMIN" ? "Admin" : "User"} + {t.role === "TENANT_ADMIN" + ? LL.Common.roleAdmin() + : LL.Common.roleUser()} @@ -221,18 +225,18 @@ export default function TenantMembers() {

      - Members of {selectedTenant.name} + {LL.Settings.membersOf({ name: selectedTenant.name })}

      {/* Add member form */}

      - Add member + {LL.Settings.addMember()}

      setNewMemberEmail(e.target.value)} @@ -247,15 +251,21 @@ export default function TenantMembers() { ) } > - - + +
      @@ -263,11 +273,11 @@ export default function TenantMembers() { {/* Member list */} {membersQuery.isLoading ? (

      - Loading members... + {LL.Settings.loadingMembers()}

      ) : members.length === 0 ? (

      - No members yet. Add one above. + {LL.Settings.noMembersYet()}

      ) : (
        @@ -283,12 +293,14 @@ export default function TenantMembers() { {m.email} {isSelf && ( - You + {LL.Common.you()} )}

        - {m.role === "TENANT_ADMIN" ? "Admin" : "User"} + {m.role === "TENANT_ADMIN" + ? LL.Common.roleAdmin() + : LL.Common.roleUser()}

    {!isSelf && ( @@ -296,7 +308,7 @@ export default function TenantMembers() { onClick={() => handleRemoveMember(m.email)} disabled={removeMember.isPending} className="shrink-0 ml-3 p-1.5 text-slate-400 hover:text-red-400 hover:bg-slate-600 rounded transition-colors disabled:opacity-50" - title="Remove member" + title={LL.Settings.removeMember()} >

    - Select a tenant from the list to manage its members. + {LL.Settings.selectTenantPrompt()}

    )} diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx index 05eaa90..60c20ae 100644 --- a/apps/web/app/verify-email/page.tsx +++ b/apps/web/app/verify-email/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect } from "react"; +import { useI18n } from "../hooks/useI18n"; /** * Email verification callback page. @@ -14,6 +15,7 @@ import { Suspense, useEffect } from "react"; * on success, so we redirect to home (not sign-in). */ function VerifyEmailContent() { + const { LL } = useI18n(); const router = useRouter(); const searchParams = useSearchParams(); const errorParam = searchParams.get("error"); @@ -45,18 +47,18 @@ function VerifyEmailContent() {

    - Verification Failed + {LL.Auth.verificationFailed()}

    {errorParam === "token_expired" - ? "This verification link has expired. Please try signing in again to receive a new verification email." - : "This verification link is invalid. Please try signing in again to receive a new verification email."} + ? LL.Auth.verificationFailedExpired() + : LL.Auth.verificationFailedInvalid()}

    - Go to Sign In + {LL.Auth.goToSignIn()} @@ -82,16 +84,14 @@ function VerifyEmailContent() {

    - Email Verified! + {LL.Auth.emailVerified()}

    -

    - Your email has been verified successfully. Redirecting to home... -

    +

    {LL.Auth.emailVerifiedMessage()}

    - Go to Home now + {LL.Auth.goToHome()} diff --git a/apps/web/i18n/en/index.ts b/apps/web/i18n/en/index.ts new file mode 100644 index 0000000..2b35927 --- /dev/null +++ b/apps/web/i18n/en/index.ts @@ -0,0 +1,251 @@ +import type { BaseTranslation } from "../i18n-types"; + +const en = { + Common: { + loading: "Loading...", + backToHome: "Back to Home", + back: "Back", + cancel: "Cancel", + save: "Save", + add: "Add", + refresh: "Refresh", + refreshing: "Refreshing...", + adding: "Adding...", + saving: "Saving...", + sending: "Sending...", + or: "Or", + roleAdmin: "Admin", + roleUser: "User", + you: "You", + edit: "Edit", + delete: "Delete", + loadingItems: "Loading items...", + noItemsYet: "No items yet. Add one to get started!", + itemCount: "{count:number} {{Item|Items}}", + itemCountGlobal: "{count:number} {{Item|Items}} (global)", + }, + Navigation: { + fullStack: "Full Stack", + profileAndSecurity: "Profile & Security", + platformTenants: "Platform Tenants", + manageMembers: "Manage Members", + signOut: "Sign out", + switchTenant: "Switch tenant", + switching: "Switching...", + selectTenant: "Select tenant", + }, + Home: { + title: "Full Stack Boilerplate", + subtitle: "NextJs (Tailwind CSS), NestJs, Expo, tRPC, Better Auth", + crudDemoTitle: "CRUD Demo", + crudDemoDescription: + "Test Create, Read, Update, Delete operations with tRPC and Prisma integration. Full-stack type safety demonstration.", + globalCrudDemoTitle: "Global CRUD Demo", + globalCrudDemoDescription: + "Same as CRUD but shared across all tenants. Everyone sees and edits the same data.", + platformTenantsTitle: "Platform Tenants", + platformTenantsDescription: + "Create, edit, and remove tenants across the platform. Super-admin only.", + manageTenants: "Manage tenants", + manageMembersTitle: "Manage Members", + manageMembersDescription: + "Add or remove members from tenants you administer.", + manageMembers: "Manage members", + authDemoTitle: "Auth Demo", + authDemoDescription: + "Test authentication with Better Auth. OAuth integration with Google, session management, and protected routes.", + tryItOut: "Try it out", + footer: "Built with modern tools for rapid development", + }, + Auth: { + signIn: "Sign in", + signInTitle: "Sign in", + signInSubtitle: "Choose your preferred sign-in method", + signInWithGoogle: "Sign in with Google", + signInWithEmailOtp: "Sign in with Email OTP", + orWithEmailPassword: "Or with Email & Password", + signInWithEmailPassword: "Sign in with Email & Password", + signingIn: "Signing in...", + signUp: "Sign up", + signUpTitle: "Create an account", + signUpSubtitle: "Sign up with your email and password", + signUpWithEmailPassword: "Sign up with Email & Password", + creatingAccount: "Creating account...", + dontHaveAccount: "Don't have an account?", + alreadyHaveAccount: "Already have an account?", + forgotPassword: "Forgot Password?", + forgotPasswordTitle: "Forgot Password", + forgotPasswordSubtitle: "Enter your email to receive a password reset link", + checkYourInbox: "Check your inbox", + sendResetLink: "Send Reset Link", + backToSignIn: "Back to Sign In", + resetLinkSentMessage: + "If an account exists with this email, you'll receive a link to set your password.", + setYourPassword: "Set Your Password", + setPasswordSubtitle: "Enter a new password for your account", + setPassword: "Set Password", + settingPassword: "Setting password...", + invalidOrExpiredLink: "Invalid or Expired Link", + invalidOrExpiredLinkMessage: + "This password reset link is invalid or has expired. Please request a new one.", + requestNewLink: "Request New Link", + verificationFailed: "Verification Failed", + verificationFailedExpired: + "This verification link has expired. Please try signing in again to receive a new verification email.", + verificationFailedInvalid: + "This verification link is invalid. Please try signing in again to receive a new verification email.", + emailVerified: "Email Verified!", + emailVerifiedMessage: + "Your email has been verified successfully. Redirecting to home...", + goToHome: "Go to Home now", + goToSignIn: "Go to Sign In", + sendOtp: "Send OTP", + verifyOtp: "Verify OTP", + verifying: "Verifying...", + changeEmail: "Change Email", + otpSentTo: "We sent a verification code to {email:string}", + checkInboxMessage: + "We've sent a verification email to {email:string}. Please check your inbox and click the link to verify your account before signing in.", + passwordSetSuccess: "Password set successfully. You can now sign in.", + signInRequired: "Sign in required", + signInRequiredMessage: + "You need to be authenticated to access this application.", + betterAuthDemo: "Better Auth Demo", + welcomeBack: "Welcome back!", + signInToContinue: "Sign in to continue", + youAreSignedIn: "You are signed in", + userInformation: "User Information", + signOutButton: "Sign Out", + orEmailPassword: "Or Email & Password", + resendVerificationEmail: "Resend verification email", + verificationEmailSent: "Verification email sent! Check your inbox.", + passwordMinChars: + "Min 8 characters with uppercase, lowercase, number, and special character.", + }, + Dashboard: { + platformTenantsTitle: "Platform Tenants", + platformTenantsSubtitle: + "Create, edit, and remove tenants. Super-admin only.", + mustBeLoggedIn: "You must be logged in to access this page.", + superAdminRequired: + "Super-admin access required. Your email must be in SUPER_ADMIN_EMAILS.", + addTenant: "Add tenant", + slugLabel: "Slug (a-z, 0-9, hyphens)", + slugPlaceholder: "e.g. acme-corp", + namePlaceholder: "e.g. Acme Corp", + tenantCount: "{count:number} tenant{{s|}}", + loadingTenants: "Loading tenants...", + noTenantsYet: "No tenants yet. Add one above.", + refreshList: "Refresh list", + deleteConfirm: 'Delete tenant "{slug:string}"? This cannot be undone.', + dualDatabaseCrudDemo: "Dual Database CRUD Demo", + crudDemoSubtitle: + "Side-by-side comparison of Mongoose (MongoDB) and Prisma (PostgreSQL)", + crudDemoTechStack: + "NextJs (TailwindCSS) \u2022 NestJs \u2022 tRPC \u2022 Transactions", + tenantDashboardLink: "\u2192 Tenant Dashboard (super-admin)", + globalCrudDemoTitle: "Global CRUD Demo", + globalCrudDemoSubtitle: + "Same as CRUD but shared across all tenants. Everyone sees and edits the same data.", + globalCrudDemoTechStack: + "Mongoose (MongoDB) & Prisma (PostgreSQL) \u2022 No tenant scope", + addTextPlaceholder: "Add text here", + }, + Settings: { + profileTitle: "Profile", + profileSubtitle: "Manage your account security and linked sign-in methods.", + emailNotVerified: "Your email is not verified", + verifyEmailPrompt: "Please verify your email to access all features.", + accountInfo: "Account Info", + linkedAccounts: "Linked Accounts", + google: "Google", + connected: "Connected", + connectGoogle: "Connect Google", + emailAndPassword: "Email & Password", + passwordSet: "Password set", + notSet: "Not set", + passwordTitle: "Password", + changePassword: "Change Password", + currentPassword: "Current Password", + newPasswordLabel: "New Password (min 8 characters)", + confirmNewPassword: "Confirm New Password", + savePassword: "Save Password", + addPasswordDescription: "Add a password to sign in without Google or OTP.", + checkInboxForPassword: "Check your inbox to set your password.", + manageMembersTitle: "Manage Members", + manageMembersSubtitle: "Add or remove members from your tenants.", + noAdminAccess: "No admin access", + noAdminAccessMessage: "You don't have admin access to any tenants.", + yourTenants: "Your tenants ({count:number})", + membersOf: "Members of {name:string}", + addMember: "Add member", + memberEmailPlaceholder: "user@example.com", + loadingMembers: "Loading members...", + noMembersYet: "No members yet. Add one above.", + removeMember: "Remove member", + selectTenantPrompt: "Select a tenant from the list to manage its members.", + }, + Forms: { + email: "Email", + emailAddress: "Email Address", + emailPlaceholder: "you@example.com", + enterYourEmail: "Enter your email", + password: "Password", + passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", + passwordMinChars: "Password (min 8 characters)", + confirmPassword: "Confirm Password", + firstName: "First Name", + firstNamePlaceholder: "John", + lastName: "Last Name", + lastNamePlaceholder: "Doe", + name: "Name", + namePlaceholder: "Your name", + verificationCode: "Verification Code", + verificationCodePlaceholder: "Enter 6-digit code", + slug: "Slug", + }, + Errors: { + failedSignInGoogle: "Failed to sign in with Google", + failedSendOtp: "Failed to send OTP. Please try again.", + unableToSendOtp: "Unable to send OTP", + invalidOtp: "Invalid OTP. Please try again.", + unableToVerifyOtp: "Unable to verify OTP", + verifyEmailBeforeSignIn: + "Please verify your email address before signing in.", + accountCreatedWithSocial: + "This account was created with Google or OTP. Use 'Forgot Password' below to set a password.", + invalidEmailOrPassword: "Invalid email or password.", + passwordsDoNotMatch: "Passwords do not match.", + passwordMinLength: "Password must be at least 8 characters.", + passwordMaxLength: "Password must be at most 128 characters.", + passwordUppercase: "Password must contain at least one uppercase letter.", + passwordLowercase: "Password must contain at least one lowercase letter.", + passwordNumber: "Password must contain at least one number.", + passwordSpecialChar: + "Password must contain at least one special character.", + failedResetPassword: "Failed to reset password. Please try again.", + failedChangePassword: "Failed to change password.", + passwordChangedSuccess: "Password changed successfully.", + signUpFailed: "Sign up failed. Please try again.", + signInFailed: "Sign in failed", + accountAlreadyExists: + "An account with this email already exists. Try signing in instead \u2014 if you used Google or OTP, you can set a password from your profile.", + nameRequired: "{label:string} is required.", + nameMinLength: "{label:string} must be at least 2 characters.", + nameInvalidChars: + "{label:string} can only contain letters, spaces, hyphens, and apostrophes.", + emailRequired: "Email is required.", + emailInvalid: "Please enter a valid email address.", + passwordRequired: "Password is required.", + slugRequired: "Slug is required", + nameFieldRequired: "Name is required", + slugInvalid: "Slug must be lowercase letters, numbers, and hyphens only", + noTenantsAssigned: "No tenants assigned", + noTenantsMessage: + "You don't have access to any tenants yet. Please contact an administrator to get added to a tenant.", + loadingYourTenants: "Loading your tenants...", + settingUpTenant: "Setting up your tenant...", + }, +} satisfies BaseTranslation; + +export default en; diff --git a/apps/web/i18n/formatters.ts b/apps/web/i18n/formatters.ts new file mode 100644 index 0000000..226226d --- /dev/null +++ b/apps/web/i18n/formatters.ts @@ -0,0 +1,13 @@ +import type { FormattersInitializer } from "typesafe-i18n"; +import type { Locales, Formatters } from "./i18n-types"; + +export const initFormatters: FormattersInitializer = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _locale: Locales, +) => { + const formatters: Formatters = { + // add your formatter functions here + }; + + return formatters; +}; diff --git a/apps/web/i18n/i18n-react.tsx b/apps/web/i18n/i18n-react.tsx new file mode 100644 index 0000000..6aa459b --- /dev/null +++ b/apps/web/i18n/i18n-react.tsx @@ -0,0 +1,29 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. + +import { useContext } from "react"; +import { initI18nReact } from "typesafe-i18n/react"; +import type { I18nContextType } from "typesafe-i18n/react"; +import type { + Formatters, + Locales, + TranslationFunctions, + Translations, +} from "./i18n-types"; +import { loadedFormatters, loadedLocales } from "./i18n-util"; + +const { component: TypesafeI18n, context: I18nContext } = initI18nReact< + Locales, + Translations, + TranslationFunctions, + Formatters +>(loadedLocales, loadedFormatters); + +const useI18nContext = (): I18nContextType< + Locales, + Translations, + TranslationFunctions +> => useContext(I18nContext); + +export { I18nContext, useI18nContext }; + +export default TypesafeI18n; diff --git a/apps/web/i18n/i18n-types.ts b/apps/web/i18n/i18n-types.ts new file mode 100644 index 0000000..1bcc569 --- /dev/null +++ b/apps/web/i18n/i18n-types.ts @@ -0,0 +1,1699 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ +import type { + BaseTranslation as BaseTranslationType, + LocalizedString, + RequiredParams, +} from "typesafe-i18n"; + +export type BaseTranslation = BaseTranslationType; +export type BaseLocale = "en"; + +export type Locales = "en" | "nl"; + +export type Translation = RootTranslation; + +export type Translations = RootTranslation; + +type RootTranslation = { + Common: { + /** + * L​o​a​d​i​n​g​.​.​. + */ + loading: string; + /** + * B​a​c​k​ ​t​o​ ​H​o​m​e + */ + backToHome: string; + /** + * B​a​c​k + */ + back: string; + /** + * C​a​n​c​e​l + */ + cancel: string; + /** + * S​a​v​e + */ + save: string; + /** + * A​d​d + */ + add: string; + /** + * R​e​f​r​e​s​h + */ + refresh: string; + /** + * R​e​f​r​e​s​h​i​n​g​.​.​. + */ + refreshing: string; + /** + * A​d​d​i​n​g​.​.​. + */ + adding: string; + /** + * S​a​v​i​n​g​.​.​. + */ + saving: string; + /** + * S​e​n​d​i​n​g​.​.​. + */ + sending: string; + /** + * O​r + */ + or: string; + /** + * A​d​m​i​n + */ + roleAdmin: string; + /** + * U​s​e​r + */ + roleUser: string; + /** + * Y​o​u + */ + you: string; + /** + * E​d​i​t + */ + edit: string; + /** + * D​e​l​e​t​e + */ + delete: string; + /** + * L​o​a​d​i​n​g​ ​i​t​e​m​s​.​.​. + */ + loadingItems: string; + /** + * N​o​ ​i​t​e​m​s​ ​y​e​t​.​ ​A​d​d​ ​o​n​e​ ​t​o​ ​g​e​t​ ​s​t​a​r​t​e​d​! + */ + noItemsYet: string; + /** + * {​c​o​u​n​t​}​ ​{​{​I​t​e​m​|​I​t​e​m​s​}​} + * @param {number} count + */ + itemCount: RequiredParams<"count">; + /** + * {​c​o​u​n​t​}​ ​{​{​I​t​e​m​|​I​t​e​m​s​}​}​ ​(​g​l​o​b​a​l​) + * @param {number} count + */ + itemCountGlobal: RequiredParams<"count">; + }; + Navigation: { + /** + * F​u​l​l​ ​S​t​a​c​k + */ + fullStack: string; + /** + * P​r​o​f​i​l​e​ ​&​ ​S​e​c​u​r​i​t​y + */ + profileAndSecurity: string; + /** + * P​l​a​t​f​o​r​m​ ​T​e​n​a​n​t​s + */ + platformTenants: string; + /** + * M​a​n​a​g​e​ ​M​e​m​b​e​r​s + */ + manageMembers: string; + /** + * S​i​g​n​ ​o​u​t + */ + signOut: string; + /** + * S​w​i​t​c​h​ ​t​e​n​a​n​t + */ + switchTenant: string; + /** + * S​w​i​t​c​h​i​n​g​.​.​. + */ + switching: string; + /** + * S​e​l​e​c​t​ ​t​e​n​a​n​t + */ + selectTenant: string; + }; + Home: { + /** + * F​u​l​l​ ​S​t​a​c​k​ ​B​o​i​l​e​r​p​l​a​t​e + */ + title: string; + /** + * N​e​x​t​J​s​ ​(​T​a​i​l​w​i​n​d​ ​C​S​S​)​,​ ​N​e​s​t​J​s​,​ ​E​x​p​o​,​ ​t​R​P​C​,​ ​B​e​t​t​e​r​ ​A​u​t​h + */ + subtitle: string; + /** + * C​R​U​D​ ​D​e​m​o + */ + crudDemoTitle: string; + /** + * T​e​s​t​ ​C​r​e​a​t​e​,​ ​R​e​a​d​,​ ​U​p​d​a​t​e​,​ ​D​e​l​e​t​e​ ​o​p​e​r​a​t​i​o​n​s​ ​w​i​t​h​ ​t​R​P​C​ ​a​n​d​ ​P​r​i​s​m​a​ ​i​n​t​e​g​r​a​t​i​o​n​.​ ​F​u​l​l​-​s​t​a​c​k​ ​t​y​p​e​ ​s​a​f​e​t​y​ ​d​e​m​o​n​s​t​r​a​t​i​o​n​. + */ + crudDemoDescription: string; + /** + * G​l​o​b​a​l​ ​C​R​U​D​ ​D​e​m​o + */ + globalCrudDemoTitle: string; + /** + * S​a​m​e​ ​a​s​ ​C​R​U​D​ ​b​u​t​ ​s​h​a​r​e​d​ ​a​c​r​o​s​s​ ​a​l​l​ ​t​e​n​a​n​t​s​.​ ​E​v​e​r​y​o​n​e​ ​s​e​e​s​ ​a​n​d​ ​e​d​i​t​s​ ​t​h​e​ ​s​a​m​e​ ​d​a​t​a​. + */ + globalCrudDemoDescription: string; + /** + * P​l​a​t​f​o​r​m​ ​T​e​n​a​n​t​s + */ + platformTenantsTitle: string; + /** + * C​r​e​a​t​e​,​ ​e​d​i​t​,​ ​a​n​d​ ​r​e​m​o​v​e​ ​t​e​n​a​n​t​s​ ​a​c​r​o​s​s​ ​t​h​e​ ​p​l​a​t​f​o​r​m​.​ ​S​u​p​e​r​-​a​d​m​i​n​ ​o​n​l​y​. + */ + platformTenantsDescription: string; + /** + * M​a​n​a​g​e​ ​t​e​n​a​n​t​s + */ + manageTenants: string; + /** + * M​a​n​a​g​e​ ​M​e​m​b​e​r​s + */ + manageMembersTitle: string; + /** + * A​d​d​ ​o​r​ ​r​e​m​o​v​e​ ​m​e​m​b​e​r​s​ ​f​r​o​m​ ​t​e​n​a​n​t​s​ ​y​o​u​ ​a​d​m​i​n​i​s​t​e​r​. + */ + manageMembersDescription: string; + /** + * M​a​n​a​g​e​ ​m​e​m​b​e​r​s + */ + manageMembers: string; + /** + * A​u​t​h​ ​D​e​m​o + */ + authDemoTitle: string; + /** + * T​e​s​t​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​w​i​t​h​ ​B​e​t​t​e​r​ ​A​u​t​h​.​ ​O​A​u​t​h​ ​i​n​t​e​g​r​a​t​i​o​n​ ​w​i​t​h​ ​G​o​o​g​l​e​,​ ​s​e​s​s​i​o​n​ ​m​a​n​a​g​e​m​e​n​t​,​ ​a​n​d​ ​p​r​o​t​e​c​t​e​d​ ​r​o​u​t​e​s​. + */ + authDemoDescription: string; + /** + * T​r​y​ ​i​t​ ​o​u​t + */ + tryItOut: string; + /** + * B​u​i​l​t​ ​w​i​t​h​ ​m​o​d​e​r​n​ ​t​o​o​l​s​ ​f​o​r​ ​r​a​p​i​d​ ​d​e​v​e​l​o​p​m​e​n​t + */ + footer: string; + }; + Auth: { + /** + * S​i​g​n​ ​i​n + */ + signIn: string; + /** + * S​i​g​n​ ​i​n + */ + signInTitle: string; + /** + * C​h​o​o​s​e​ ​y​o​u​r​ ​p​r​e​f​e​r​r​e​d​ ​s​i​g​n​-​i​n​ ​m​e​t​h​o​d + */ + signInSubtitle: string; + /** + * S​i​g​n​ ​i​n​ ​w​i​t​h​ ​G​o​o​g​l​e + */ + signInWithGoogle: string; + /** + * S​i​g​n​ ​i​n​ ​w​i​t​h​ ​E​m​a​i​l​ ​O​T​P + */ + signInWithEmailOtp: string; + /** + * O​r​ ​w​i​t​h​ ​E​m​a​i​l​ ​&​ ​P​a​s​s​w​o​r​d + */ + orWithEmailPassword: string; + /** + * S​i​g​n​ ​i​n​ ​w​i​t​h​ ​E​m​a​i​l​ ​&​ ​P​a​s​s​w​o​r​d + */ + signInWithEmailPassword: string; + /** + * S​i​g​n​i​n​g​ ​i​n​.​.​. + */ + signingIn: string; + /** + * S​i​g​n​ ​u​p + */ + signUp: string; + /** + * C​r​e​a​t​e​ ​a​n​ ​a​c​c​o​u​n​t + */ + signUpTitle: string; + /** + * S​i​g​n​ ​u​p​ ​w​i​t​h​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​n​d​ ​p​a​s​s​w​o​r​d + */ + signUpSubtitle: string; + /** + * S​i​g​n​ ​u​p​ ​w​i​t​h​ ​E​m​a​i​l​ ​&​ ​P​a​s​s​w​o​r​d + */ + signUpWithEmailPassword: string; + /** + * C​r​e​a​t​i​n​g​ ​a​c​c​o​u​n​t​.​.​. + */ + creatingAccount: string; + /** + * D​o​n​'​t​ ​h​a​v​e​ ​a​n​ ​a​c​c​o​u​n​t​? + */ + dontHaveAccount: string; + /** + * A​l​r​e​a​d​y​ ​h​a​v​e​ ​a​n​ ​a​c​c​o​u​n​t​? + */ + alreadyHaveAccount: string; + /** + * F​o​r​g​o​t​ ​P​a​s​s​w​o​r​d​? + */ + forgotPassword: string; + /** + * F​o​r​g​o​t​ ​P​a​s​s​w​o​r​d + */ + forgotPasswordTitle: string; + /** + * E​n​t​e​r​ ​y​o​u​r​ ​e​m​a​i​l​ ​t​o​ ​r​e​c​e​i​v​e​ ​a​ ​p​a​s​s​w​o​r​d​ ​r​e​s​e​t​ ​l​i​n​k + */ + forgotPasswordSubtitle: string; + /** + * C​h​e​c​k​ ​y​o​u​r​ ​i​n​b​o​x + */ + checkYourInbox: string; + /** + * S​e​n​d​ ​R​e​s​e​t​ ​L​i​n​k + */ + sendResetLink: string; + /** + * B​a​c​k​ ​t​o​ ​S​i​g​n​ ​I​n + */ + backToSignIn: string; + /** + * I​f​ ​a​n​ ​a​c​c​o​u​n​t​ ​e​x​i​s​t​s​ ​w​i​t​h​ ​t​h​i​s​ ​e​m​a​i​l​,​ ​y​o​u​'​l​l​ ​r​e​c​e​i​v​e​ ​a​ ​l​i​n​k​ ​t​o​ ​s​e​t​ ​y​o​u​r​ ​p​a​s​s​w​o​r​d​. + */ + resetLinkSentMessage: string; + /** + * S​e​t​ ​Y​o​u​r​ ​P​a​s​s​w​o​r​d + */ + setYourPassword: string; + /** + * E​n​t​e​r​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​f​o​r​ ​y​o​u​r​ ​a​c​c​o​u​n​t + */ + setPasswordSubtitle: string; + /** + * S​e​t​ ​P​a​s​s​w​o​r​d + */ + setPassword: string; + /** + * S​e​t​t​i​n​g​ ​p​a​s​s​w​o​r​d​.​.​. + */ + settingPassword: string; + /** + * I​n​v​a​l​i​d​ ​o​r​ ​E​x​p​i​r​e​d​ ​L​i​n​k + */ + invalidOrExpiredLink: string; + /** + * T​h​i​s​ ​p​a​s​s​w​o​r​d​ ​r​e​s​e​t​ ​l​i​n​k​ ​i​s​ ​i​n​v​a​l​i​d​ ​o​r​ ​h​a​s​ ​e​x​p​i​r​e​d​.​ ​P​l​e​a​s​e​ ​r​e​q​u​e​s​t​ ​a​ ​n​e​w​ ​o​n​e​. + */ + invalidOrExpiredLinkMessage: string; + /** + * R​e​q​u​e​s​t​ ​N​e​w​ ​L​i​n​k + */ + requestNewLink: string; + /** + * V​e​r​i​f​i​c​a​t​i​o​n​ ​F​a​i​l​e​d + */ + verificationFailed: string; + /** + * T​h​i​s​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​l​i​n​k​ ​h​a​s​ ​e​x​p​i​r​e​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​s​i​g​n​i​n​g​ ​i​n​ ​a​g​a​i​n​ ​t​o​ ​r​e​c​e​i​v​e​ ​a​ ​n​e​w​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​e​m​a​i​l​. + */ + verificationFailedExpired: string; + /** + * T​h​i​s​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​l​i​n​k​ ​i​s​ ​i​n​v​a​l​i​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​s​i​g​n​i​n​g​ ​i​n​ ​a​g​a​i​n​ ​t​o​ ​r​e​c​e​i​v​e​ ​a​ ​n​e​w​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​e​m​a​i​l​. + */ + verificationFailedInvalid: string; + /** + * E​m​a​i​l​ ​V​e​r​i​f​i​e​d​! + */ + emailVerified: string; + /** + * Y​o​u​r​ ​e​m​a​i​l​ ​h​a​s​ ​b​e​e​n​ ​v​e​r​i​f​i​e​d​ ​s​u​c​c​e​s​s​f​u​l​l​y​.​ ​R​e​d​i​r​e​c​t​i​n​g​ ​t​o​ ​h​o​m​e​.​.​. + */ + emailVerifiedMessage: string; + /** + * G​o​ ​t​o​ ​H​o​m​e​ ​n​o​w + */ + goToHome: string; + /** + * G​o​ ​t​o​ ​S​i​g​n​ ​I​n + */ + goToSignIn: string; + /** + * S​e​n​d​ ​O​T​P + */ + sendOtp: string; + /** + * V​e​r​i​f​y​ ​O​T​P + */ + verifyOtp: string; + /** + * V​e​r​i​f​y​i​n​g​.​.​. + */ + verifying: string; + /** + * C​h​a​n​g​e​ ​E​m​a​i​l + */ + changeEmail: string; + /** + * W​e​ ​s​e​n​t​ ​a​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​c​o​d​e​ ​t​o​ ​{​e​m​a​i​l​} + * @param {string} email + */ + otpSentTo: RequiredParams<"email">; + /** + * W​e​'​v​e​ ​s​e​n​t​ ​a​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​e​m​a​i​l​ ​t​o​ ​{​e​m​a​i​l​}​.​ ​P​l​e​a​s​e​ ​c​h​e​c​k​ ​y​o​u​r​ ​i​n​b​o​x​ ​a​n​d​ ​c​l​i​c​k​ ​t​h​e​ ​l​i​n​k​ ​t​o​ ​v​e​r​i​f​y​ ​y​o​u​r​ ​a​c​c​o​u​n​t​ ​b​e​f​o​r​e​ ​s​i​g​n​i​n​g​ ​i​n​. + * @param {string} email + */ + checkInboxMessage: RequiredParams<"email">; + /** + * P​a​s​s​w​o​r​d​ ​s​e​t​ ​s​u​c​c​e​s​s​f​u​l​l​y​.​ ​Y​o​u​ ​c​a​n​ ​n​o​w​ ​s​i​g​n​ ​i​n​. + */ + passwordSetSuccess: string; + /** + * S​i​g​n​ ​i​n​ ​r​e​q​u​i​r​e​d + */ + signInRequired: string; + /** + * Y​o​u​ ​n​e​e​d​ ​t​o​ ​b​e​ ​a​u​t​h​e​n​t​i​c​a​t​e​d​ ​t​o​ ​a​c​c​e​s​s​ ​t​h​i​s​ ​a​p​p​l​i​c​a​t​i​o​n​. + */ + signInRequiredMessage: string; + /** + * B​e​t​t​e​r​ ​A​u​t​h​ ​D​e​m​o + */ + betterAuthDemo: string; + /** + * W​e​l​c​o​m​e​ ​b​a​c​k​! + */ + welcomeBack: string; + /** + * S​i​g​n​ ​i​n​ ​t​o​ ​c​o​n​t​i​n​u​e + */ + signInToContinue: string; + /** + * Y​o​u​ ​a​r​e​ ​s​i​g​n​e​d​ ​i​n + */ + youAreSignedIn: string; + /** + * U​s​e​r​ ​I​n​f​o​r​m​a​t​i​o​n + */ + userInformation: string; + /** + * S​i​g​n​ ​O​u​t + */ + signOutButton: string; + /** + * O​r​ ​E​m​a​i​l​ ​&​ ​P​a​s​s​w​o​r​d + */ + orEmailPassword: string; + /** + * R​e​s​e​n​d​ ​v​e​r​i​f​i​c​a​t​i​o​n​ ​e​m​a​i​l + */ + resendVerificationEmail: string; + /** + * V​e​r​i​f​i​c​a​t​i​o​n​ ​e​m​a​i​l​ ​s​e​n​t​!​ ​C​h​e​c​k​ ​y​o​u​r​ ​i​n​b​o​x​. + */ + verificationEmailSent: string; + /** + * M​i​n​ ​8​ ​c​h​a​r​a​c​t​e​r​s​ ​w​i​t​h​ ​u​p​p​e​r​c​a​s​e​,​ ​l​o​w​e​r​c​a​s​e​,​ ​n​u​m​b​e​r​,​ ​a​n​d​ ​s​p​e​c​i​a​l​ ​c​h​a​r​a​c​t​e​r​. + */ + passwordMinChars: string; + }; + Dashboard: { + /** + * P​l​a​t​f​o​r​m​ ​T​e​n​a​n​t​s + */ + platformTenantsTitle: string; + /** + * C​r​e​a​t​e​,​ ​e​d​i​t​,​ ​a​n​d​ ​r​e​m​o​v​e​ ​t​e​n​a​n​t​s​.​ ​S​u​p​e​r​-​a​d​m​i​n​ ​o​n​l​y​. + */ + platformTenantsSubtitle: string; + /** + * Y​o​u​ ​m​u​s​t​ ​b​e​ ​l​o​g​g​e​d​ ​i​n​ ​t​o​ ​a​c​c​e​s​s​ ​t​h​i​s​ ​p​a​g​e​. + */ + mustBeLoggedIn: string; + /** + * S​u​p​e​r​-​a​d​m​i​n​ ​a​c​c​e​s​s​ ​r​e​q​u​i​r​e​d​.​ ​Y​o​u​r​ ​e​m​a​i​l​ ​m​u​s​t​ ​b​e​ ​i​n​ ​S​U​P​E​R​_​A​D​M​I​N​_​E​M​A​I​L​S​. + */ + superAdminRequired: string; + /** + * A​d​d​ ​t​e​n​a​n​t + */ + addTenant: string; + /** + * S​l​u​g​ ​(​a​-​z​,​ ​0​-​9​,​ ​h​y​p​h​e​n​s​) + */ + slugLabel: string; + /** + * e​.​g​.​ ​a​c​m​e​-​c​o​r​p + */ + slugPlaceholder: string; + /** + * e​.​g​.​ ​A​c​m​e​ ​C​o​r​p + */ + namePlaceholder: string; + /** + * {​c​o​u​n​t​}​ ​t​e​n​a​n​t​{​{​s​|​}​} + * @param {number} count + */ + tenantCount: RequiredParams<"count">; + /** + * L​o​a​d​i​n​g​ ​t​e​n​a​n​t​s​.​.​. + */ + loadingTenants: string; + /** + * N​o​ ​t​e​n​a​n​t​s​ ​y​e​t​.​ ​A​d​d​ ​o​n​e​ ​a​b​o​v​e​. + */ + noTenantsYet: string; + /** + * R​e​f​r​e​s​h​ ​l​i​s​t + */ + refreshList: string; + /** + * D​e​l​e​t​e​ ​t​e​n​a​n​t​ ​"​{​s​l​u​g​}​"​?​ ​T​h​i​s​ ​c​a​n​n​o​t​ ​b​e​ ​u​n​d​o​n​e​. + * @param {string} slug + */ + deleteConfirm: RequiredParams<"slug">; + /** + * D​u​a​l​ ​D​a​t​a​b​a​s​e​ ​C​R​U​D​ ​D​e​m​o + */ + dualDatabaseCrudDemo: string; + /** + * S​i​d​e​-​b​y​-​s​i​d​e​ ​c​o​m​p​a​r​i​s​o​n​ ​o​f​ ​M​o​n​g​o​o​s​e​ ​(​M​o​n​g​o​D​B​)​ ​a​n​d​ ​P​r​i​s​m​a​ ​(​P​o​s​t​g​r​e​S​Q​L​) + */ + crudDemoSubtitle: string; + /** + * N​e​x​t​J​s​ ​(​T​a​i​l​w​i​n​d​C​S​S​)​ ​•​ ​N​e​s​t​J​s​ ​•​ ​t​R​P​C​ ​•​ ​T​r​a​n​s​a​c​t​i​o​n​s + */ + crudDemoTechStack: string; + /** + * →​ ​T​e​n​a​n​t​ ​D​a​s​h​b​o​a​r​d​ ​(​s​u​p​e​r​-​a​d​m​i​n​) + */ + tenantDashboardLink: string; + /** + * G​l​o​b​a​l​ ​C​R​U​D​ ​D​e​m​o + */ + globalCrudDemoTitle: string; + /** + * S​a​m​e​ ​a​s​ ​C​R​U​D​ ​b​u​t​ ​s​h​a​r​e​d​ ​a​c​r​o​s​s​ ​a​l​l​ ​t​e​n​a​n​t​s​.​ ​E​v​e​r​y​o​n​e​ ​s​e​e​s​ ​a​n​d​ ​e​d​i​t​s​ ​t​h​e​ ​s​a​m​e​ ​d​a​t​a​. + */ + globalCrudDemoSubtitle: string; + /** + * M​o​n​g​o​o​s​e​ ​(​M​o​n​g​o​D​B​)​ ​&​ ​P​r​i​s​m​a​ ​(​P​o​s​t​g​r​e​S​Q​L​)​ ​•​ ​N​o​ ​t​e​n​a​n​t​ ​s​c​o​p​e + */ + globalCrudDemoTechStack: string; + /** + * A​d​d​ ​t​e​x​t​ ​h​e​r​e + */ + addTextPlaceholder: string; + }; + Settings: { + /** + * P​r​o​f​i​l​e + */ + profileTitle: string; + /** + * M​a​n​a​g​e​ ​y​o​u​r​ ​a​c​c​o​u​n​t​ ​s​e​c​u​r​i​t​y​ ​a​n​d​ ​l​i​n​k​e​d​ ​s​i​g​n​-​i​n​ ​m​e​t​h​o​d​s​. + */ + profileSubtitle: string; + /** + * Y​o​u​r​ ​e​m​a​i​l​ ​i​s​ ​n​o​t​ ​v​e​r​i​f​i​e​d + */ + emailNotVerified: string; + /** + * P​l​e​a​s​e​ ​v​e​r​i​f​y​ ​y​o​u​r​ ​e​m​a​i​l​ ​t​o​ ​a​c​c​e​s​s​ ​a​l​l​ ​f​e​a​t​u​r​e​s​. + */ + verifyEmailPrompt: string; + /** + * A​c​c​o​u​n​t​ ​I​n​f​o + */ + accountInfo: string; + /** + * L​i​n​k​e​d​ ​A​c​c​o​u​n​t​s + */ + linkedAccounts: string; + /** + * G​o​o​g​l​e + */ + google: string; + /** + * C​o​n​n​e​c​t​e​d + */ + connected: string; + /** + * C​o​n​n​e​c​t​ ​G​o​o​g​l​e + */ + connectGoogle: string; + /** + * E​m​a​i​l​ ​&​ ​P​a​s​s​w​o​r​d + */ + emailAndPassword: string; + /** + * P​a​s​s​w​o​r​d​ ​s​e​t + */ + passwordSet: string; + /** + * N​o​t​ ​s​e​t + */ + notSet: string; + /** + * P​a​s​s​w​o​r​d + */ + passwordTitle: string; + /** + * C​h​a​n​g​e​ ​P​a​s​s​w​o​r​d + */ + changePassword: string; + /** + * C​u​r​r​e​n​t​ ​P​a​s​s​w​o​r​d + */ + currentPassword: string; + /** + * N​e​w​ ​P​a​s​s​w​o​r​d​ ​(​m​i​n​ ​8​ ​c​h​a​r​a​c​t​e​r​s​) + */ + newPasswordLabel: string; + /** + * C​o​n​f​i​r​m​ ​N​e​w​ ​P​a​s​s​w​o​r​d + */ + confirmNewPassword: string; + /** + * S​a​v​e​ ​P​a​s​s​w​o​r​d + */ + savePassword: string; + /** + * A​d​d​ ​a​ ​p​a​s​s​w​o​r​d​ ​t​o​ ​s​i​g​n​ ​i​n​ ​w​i​t​h​o​u​t​ ​G​o​o​g​l​e​ ​o​r​ ​O​T​P​. + */ + addPasswordDescription: string; + /** + * C​h​e​c​k​ ​y​o​u​r​ ​i​n​b​o​x​ ​t​o​ ​s​e​t​ ​y​o​u​r​ ​p​a​s​s​w​o​r​d​. + */ + checkInboxForPassword: string; + /** + * M​a​n​a​g​e​ ​M​e​m​b​e​r​s + */ + manageMembersTitle: string; + /** + * A​d​d​ ​o​r​ ​r​e​m​o​v​e​ ​m​e​m​b​e​r​s​ ​f​r​o​m​ ​y​o​u​r​ ​t​e​n​a​n​t​s​. + */ + manageMembersSubtitle: string; + /** + * N​o​ ​a​d​m​i​n​ ​a​c​c​e​s​s + */ + noAdminAccess: string; + /** + * Y​o​u​ ​d​o​n​'​t​ ​h​a​v​e​ ​a​d​m​i​n​ ​a​c​c​e​s​s​ ​t​o​ ​a​n​y​ ​t​e​n​a​n​t​s​. + */ + noAdminAccessMessage: string; + /** + * Y​o​u​r​ ​t​e​n​a​n​t​s​ ​(​{​c​o​u​n​t​}​) + * @param {number} count + */ + yourTenants: RequiredParams<"count">; + /** + * M​e​m​b​e​r​s​ ​o​f​ ​{​n​a​m​e​} + * @param {string} name + */ + membersOf: RequiredParams<"name">; + /** + * A​d​d​ ​m​e​m​b​e​r + */ + addMember: string; + /** + * u​s​e​r​@​e​x​a​m​p​l​e​.​c​o​m + */ + memberEmailPlaceholder: string; + /** + * L​o​a​d​i​n​g​ ​m​e​m​b​e​r​s​.​.​. + */ + loadingMembers: string; + /** + * N​o​ ​m​e​m​b​e​r​s​ ​y​e​t​.​ ​A​d​d​ ​o​n​e​ ​a​b​o​v​e​. + */ + noMembersYet: string; + /** + * R​e​m​o​v​e​ ​m​e​m​b​e​r + */ + removeMember: string; + /** + * S​e​l​e​c​t​ ​a​ ​t​e​n​a​n​t​ ​f​r​o​m​ ​t​h​e​ ​l​i​s​t​ ​t​o​ ​m​a​n​a​g​e​ ​i​t​s​ ​m​e​m​b​e​r​s​. + */ + selectTenantPrompt: string; + }; + Forms: { + /** + * E​m​a​i​l + */ + email: string; + /** + * E​m​a​i​l​ ​A​d​d​r​e​s​s + */ + emailAddress: string; + /** + * y​o​u​@​e​x​a​m​p​l​e​.​c​o​m + */ + emailPlaceholder: string; + /** + * E​n​t​e​r​ ​y​o​u​r​ ​e​m​a​i​l + */ + enterYourEmail: string; + /** + * P​a​s​s​w​o​r​d + */ + password: string; + /** + * •​•​•​•​•​•​•​• + */ + passwordPlaceholder: string; + /** + * P​a​s​s​w​o​r​d​ ​(​m​i​n​ ​8​ ​c​h​a​r​a​c​t​e​r​s​) + */ + passwordMinChars: string; + /** + * C​o​n​f​i​r​m​ ​P​a​s​s​w​o​r​d + */ + confirmPassword: string; + /** + * F​i​r​s​t​ ​N​a​m​e + */ + firstName: string; + /** + * J​o​h​n + */ + firstNamePlaceholder: string; + /** + * L​a​s​t​ ​N​a​m​e + */ + lastName: string; + /** + * D​o​e + */ + lastNamePlaceholder: string; + /** + * N​a​m​e + */ + name: string; + /** + * Y​o​u​r​ ​n​a​m​e + */ + namePlaceholder: string; + /** + * V​e​r​i​f​i​c​a​t​i​o​n​ ​C​o​d​e + */ + verificationCode: string; + /** + * E​n​t​e​r​ ​6​-​d​i​g​i​t​ ​c​o​d​e + */ + verificationCodePlaceholder: string; + /** + * S​l​u​g + */ + slug: string; + }; + Errors: { + /** + * F​a​i​l​e​d​ ​t​o​ ​s​i​g​n​ ​i​n​ ​w​i​t​h​ ​G​o​o​g​l​e + */ + failedSignInGoogle: string; + /** + * F​a​i​l​e​d​ ​t​o​ ​s​e​n​d​ ​O​T​P​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + failedSendOtp: string; + /** + * U​n​a​b​l​e​ ​t​o​ ​s​e​n​d​ ​O​T​P + */ + unableToSendOtp: string; + /** + * I​n​v​a​l​i​d​ ​O​T​P​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + invalidOtp: string; + /** + * U​n​a​b​l​e​ ​t​o​ ​v​e​r​i​f​y​ ​O​T​P + */ + unableToVerifyOtp: string; + /** + * P​l​e​a​s​e​ ​v​e​r​i​f​y​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​ ​b​e​f​o​r​e​ ​s​i​g​n​i​n​g​ ​i​n​. + */ + verifyEmailBeforeSignIn: string; + /** + * T​h​i​s​ ​a​c​c​o​u​n​t​ ​w​a​s​ ​c​r​e​a​t​e​d​ ​w​i​t​h​ ​G​o​o​g​l​e​ ​o​r​ ​O​T​P​.​ ​U​s​e​ ​'​F​o​r​g​o​t​ ​P​a​s​s​w​o​r​d​'​ ​b​e​l​o​w​ ​t​o​ ​s​e​t​ ​a​ ​p​a​s​s​w​o​r​d​. + */ + accountCreatedWithSocial: string; + /** + * I​n​v​a​l​i​d​ ​e​m​a​i​l​ ​o​r​ ​p​a​s​s​w​o​r​d​. + */ + invalidEmailOrPassword: string; + /** + * P​a​s​s​w​o​r​d​s​ ​d​o​ ​n​o​t​ ​m​a​t​c​h​. + */ + passwordsDoNotMatch: string; + /** + * P​a​s​s​w​o​r​d​ ​m​u​s​t​ ​b​e​ ​a​t​ ​l​e​a​s​t​ ​8​ ​c​h​a​r​a​c​t​e​r​s​. + */ + passwordMinLength: string; + /** + * P​a​s​s​w​o​r​d​ ​m​u​s​t​ ​b​e​ ​a​t​ ​m​o​s​t​ ​1​2​8​ ​c​h​a​r​a​c​t​e​r​s​. + */ + passwordMaxLength: string; + /** + * P​a​s​s​w​o​r​d​ ​m​u​s​t​ ​c​o​n​t​a​i​n​ ​a​t​ ​l​e​a​s​t​ ​o​n​e​ ​u​p​p​e​r​c​a​s​e​ ​l​e​t​t​e​r​. + */ + passwordUppercase: string; + /** + * P​a​s​s​w​o​r​d​ ​m​u​s​t​ ​c​o​n​t​a​i​n​ ​a​t​ ​l​e​a​s​t​ ​o​n​e​ ​l​o​w​e​r​c​a​s​e​ ​l​e​t​t​e​r​. + */ + passwordLowercase: string; + /** + * P​a​s​s​w​o​r​d​ ​m​u​s​t​ ​c​o​n​t​a​i​n​ ​a​t​ ​l​e​a​s​t​ ​o​n​e​ ​n​u​m​b​e​r​. + */ + passwordNumber: string; + /** + * P​a​s​s​w​o​r​d​ ​m​u​s​t​ ​c​o​n​t​a​i​n​ ​a​t​ ​l​e​a​s​t​ ​o​n​e​ ​s​p​e​c​i​a​l​ ​c​h​a​r​a​c​t​e​r​. + */ + passwordSpecialChar: string; + /** + * F​a​i​l​e​d​ ​t​o​ ​r​e​s​e​t​ ​p​a​s​s​w​o​r​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + failedResetPassword: string; + /** + * F​a​i​l​e​d​ ​t​o​ ​c​h​a​n​g​e​ ​p​a​s​s​w​o​r​d​. + */ + failedChangePassword: string; + /** + * P​a​s​s​w​o​r​d​ ​c​h​a​n​g​e​d​ ​s​u​c​c​e​s​s​f​u​l​l​y​. + */ + passwordChangedSuccess: string; + /** + * S​i​g​n​ ​u​p​ ​f​a​i​l​e​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + signUpFailed: string; + /** + * S​i​g​n​ ​i​n​ ​f​a​i​l​e​d + */ + signInFailed: string; + /** + * A​n​ ​a​c​c​o​u​n​t​ ​w​i​t​h​ ​t​h​i​s​ ​e​m​a​i​l​ ​a​l​r​e​a​d​y​ ​e​x​i​s​t​s​.​ ​T​r​y​ ​s​i​g​n​i​n​g​ ​i​n​ ​i​n​s​t​e​a​d​ ​—​ ​i​f​ ​y​o​u​ ​u​s​e​d​ ​G​o​o​g​l​e​ ​o​r​ ​O​T​P​,​ ​y​o​u​ ​c​a​n​ ​s​e​t​ ​a​ ​p​a​s​s​w​o​r​d​ ​f​r​o​m​ ​y​o​u​r​ ​p​r​o​f​i​l​e​. + */ + accountAlreadyExists: string; + /** + * {​l​a​b​e​l​}​ ​i​s​ ​r​e​q​u​i​r​e​d​. + * @param {string} label + */ + nameRequired: RequiredParams<"label">; + /** + * {​l​a​b​e​l​}​ ​m​u​s​t​ ​b​e​ ​a​t​ ​l​e​a​s​t​ ​2​ ​c​h​a​r​a​c​t​e​r​s​. + * @param {string} label + */ + nameMinLength: RequiredParams<"label">; + /** + * {​l​a​b​e​l​}​ ​c​a​n​ ​o​n​l​y​ ​c​o​n​t​a​i​n​ ​l​e​t​t​e​r​s​,​ ​s​p​a​c​e​s​,​ ​h​y​p​h​e​n​s​,​ ​a​n​d​ ​a​p​o​s​t​r​o​p​h​e​s​. + * @param {string} label + */ + nameInvalidChars: RequiredParams<"label">; + /** + * E​m​a​i​l​ ​i​s​ ​r​e​q​u​i​r​e​d​. + */ + emailRequired: string; + /** + * P​l​e​a​s​e​ ​e​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​. + */ + emailInvalid: string; + /** + * P​a​s​s​w​o​r​d​ ​i​s​ ​r​e​q​u​i​r​e​d​. + */ + passwordRequired: string; + /** + * S​l​u​g​ ​i​s​ ​r​e​q​u​i​r​e​d + */ + slugRequired: string; + /** + * N​a​m​e​ ​i​s​ ​r​e​q​u​i​r​e​d + */ + nameFieldRequired: string; + /** + * S​l​u​g​ ​m​u​s​t​ ​b​e​ ​l​o​w​e​r​c​a​s​e​ ​l​e​t​t​e​r​s​,​ ​n​u​m​b​e​r​s​,​ ​a​n​d​ ​h​y​p​h​e​n​s​ ​o​n​l​y + */ + slugInvalid: string; + /** + * N​o​ ​t​e​n​a​n​t​s​ ​a​s​s​i​g​n​e​d + */ + noTenantsAssigned: string; + /** + * Y​o​u​ ​d​o​n​'​t​ ​h​a​v​e​ ​a​c​c​e​s​s​ ​t​o​ ​a​n​y​ ​t​e​n​a​n​t​s​ ​y​e​t​.​ ​P​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​a​n​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​t​o​ ​g​e​t​ ​a​d​d​e​d​ ​t​o​ ​a​ ​t​e​n​a​n​t​. + */ + noTenantsMessage: string; + /** + * L​o​a​d​i​n​g​ ​y​o​u​r​ ​t​e​n​a​n​t​s​.​.​. + */ + loadingYourTenants: string; + /** + * S​e​t​t​i​n​g​ ​u​p​ ​y​o​u​r​ ​t​e​n​a​n​t​.​.​. + */ + settingUpTenant: string; + }; +}; + +export type TranslationFunctions = { + Common: { + /** + * Loading... + */ + loading: () => LocalizedString; + /** + * Back to Home + */ + backToHome: () => LocalizedString; + /** + * Back + */ + back: () => LocalizedString; + /** + * Cancel + */ + cancel: () => LocalizedString; + /** + * Save + */ + save: () => LocalizedString; + /** + * Add + */ + add: () => LocalizedString; + /** + * Refresh + */ + refresh: () => LocalizedString; + /** + * Refreshing... + */ + refreshing: () => LocalizedString; + /** + * Adding... + */ + adding: () => LocalizedString; + /** + * Saving... + */ + saving: () => LocalizedString; + /** + * Sending... + */ + sending: () => LocalizedString; + /** + * Or + */ + or: () => LocalizedString; + /** + * Admin + */ + roleAdmin: () => LocalizedString; + /** + * User + */ + roleUser: () => LocalizedString; + /** + * You + */ + you: () => LocalizedString; + /** + * Edit + */ + edit: () => LocalizedString; + /** + * Delete + */ + delete: () => LocalizedString; + /** + * Loading items... + */ + loadingItems: () => LocalizedString; + /** + * No items yet. Add one to get started! + */ + noItemsYet: () => LocalizedString; + /** + * {count} {{Item|Items}} + */ + itemCount: (arg: { count: number }) => LocalizedString; + /** + * {count} {{Item|Items}} (global) + */ + itemCountGlobal: (arg: { count: number }) => LocalizedString; + }; + Navigation: { + /** + * Full Stack + */ + fullStack: () => LocalizedString; + /** + * Profile & Security + */ + profileAndSecurity: () => LocalizedString; + /** + * Platform Tenants + */ + platformTenants: () => LocalizedString; + /** + * Manage Members + */ + manageMembers: () => LocalizedString; + /** + * Sign out + */ + signOut: () => LocalizedString; + /** + * Switch tenant + */ + switchTenant: () => LocalizedString; + /** + * Switching... + */ + switching: () => LocalizedString; + /** + * Select tenant + */ + selectTenant: () => LocalizedString; + }; + Home: { + /** + * Full Stack Boilerplate + */ + title: () => LocalizedString; + /** + * NextJs (Tailwind CSS), NestJs, Expo, tRPC, Better Auth + */ + subtitle: () => LocalizedString; + /** + * CRUD Demo + */ + crudDemoTitle: () => LocalizedString; + /** + * Test Create, Read, Update, Delete operations with tRPC and Prisma integration. Full-stack type safety demonstration. + */ + crudDemoDescription: () => LocalizedString; + /** + * Global CRUD Demo + */ + globalCrudDemoTitle: () => LocalizedString; + /** + * Same as CRUD but shared across all tenants. Everyone sees and edits the same data. + */ + globalCrudDemoDescription: () => LocalizedString; + /** + * Platform Tenants + */ + platformTenantsTitle: () => LocalizedString; + /** + * Create, edit, and remove tenants across the platform. Super-admin only. + */ + platformTenantsDescription: () => LocalizedString; + /** + * Manage tenants + */ + manageTenants: () => LocalizedString; + /** + * Manage Members + */ + manageMembersTitle: () => LocalizedString; + /** + * Add or remove members from tenants you administer. + */ + manageMembersDescription: () => LocalizedString; + /** + * Manage members + */ + manageMembers: () => LocalizedString; + /** + * Auth Demo + */ + authDemoTitle: () => LocalizedString; + /** + * Test authentication with Better Auth. OAuth integration with Google, session management, and protected routes. + */ + authDemoDescription: () => LocalizedString; + /** + * Try it out + */ + tryItOut: () => LocalizedString; + /** + * Built with modern tools for rapid development + */ + footer: () => LocalizedString; + }; + Auth: { + /** + * Sign in + */ + signIn: () => LocalizedString; + /** + * Sign in + */ + signInTitle: () => LocalizedString; + /** + * Choose your preferred sign-in method + */ + signInSubtitle: () => LocalizedString; + /** + * Sign in with Google + */ + signInWithGoogle: () => LocalizedString; + /** + * Sign in with Email OTP + */ + signInWithEmailOtp: () => LocalizedString; + /** + * Or with Email & Password + */ + orWithEmailPassword: () => LocalizedString; + /** + * Sign in with Email & Password + */ + signInWithEmailPassword: () => LocalizedString; + /** + * Signing in... + */ + signingIn: () => LocalizedString; + /** + * Sign up + */ + signUp: () => LocalizedString; + /** + * Create an account + */ + signUpTitle: () => LocalizedString; + /** + * Sign up with your email and password + */ + signUpSubtitle: () => LocalizedString; + /** + * Sign up with Email & Password + */ + signUpWithEmailPassword: () => LocalizedString; + /** + * Creating account... + */ + creatingAccount: () => LocalizedString; + /** + * Don't have an account? + */ + dontHaveAccount: () => LocalizedString; + /** + * Already have an account? + */ + alreadyHaveAccount: () => LocalizedString; + /** + * Forgot Password? + */ + forgotPassword: () => LocalizedString; + /** + * Forgot Password + */ + forgotPasswordTitle: () => LocalizedString; + /** + * Enter your email to receive a password reset link + */ + forgotPasswordSubtitle: () => LocalizedString; + /** + * Check your inbox + */ + checkYourInbox: () => LocalizedString; + /** + * Send Reset Link + */ + sendResetLink: () => LocalizedString; + /** + * Back to Sign In + */ + backToSignIn: () => LocalizedString; + /** + * If an account exists with this email, you'll receive a link to set your password. + */ + resetLinkSentMessage: () => LocalizedString; + /** + * Set Your Password + */ + setYourPassword: () => LocalizedString; + /** + * Enter a new password for your account + */ + setPasswordSubtitle: () => LocalizedString; + /** + * Set Password + */ + setPassword: () => LocalizedString; + /** + * Setting password... + */ + settingPassword: () => LocalizedString; + /** + * Invalid or Expired Link + */ + invalidOrExpiredLink: () => LocalizedString; + /** + * This password reset link is invalid or has expired. Please request a new one. + */ + invalidOrExpiredLinkMessage: () => LocalizedString; + /** + * Request New Link + */ + requestNewLink: () => LocalizedString; + /** + * Verification Failed + */ + verificationFailed: () => LocalizedString; + /** + * This verification link has expired. Please try signing in again to receive a new verification email. + */ + verificationFailedExpired: () => LocalizedString; + /** + * This verification link is invalid. Please try signing in again to receive a new verification email. + */ + verificationFailedInvalid: () => LocalizedString; + /** + * Email Verified! + */ + emailVerified: () => LocalizedString; + /** + * Your email has been verified successfully. Redirecting to home... + */ + emailVerifiedMessage: () => LocalizedString; + /** + * Go to Home now + */ + goToHome: () => LocalizedString; + /** + * Go to Sign In + */ + goToSignIn: () => LocalizedString; + /** + * Send OTP + */ + sendOtp: () => LocalizedString; + /** + * Verify OTP + */ + verifyOtp: () => LocalizedString; + /** + * Verifying... + */ + verifying: () => LocalizedString; + /** + * Change Email + */ + changeEmail: () => LocalizedString; + /** + * We sent a verification code to {email} + */ + otpSentTo: (arg: { email: string }) => LocalizedString; + /** + * We've sent a verification email to {email}. Please check your inbox and click the link to verify your account before signing in. + */ + checkInboxMessage: (arg: { email: string }) => LocalizedString; + /** + * Password set successfully. You can now sign in. + */ + passwordSetSuccess: () => LocalizedString; + /** + * Sign in required + */ + signInRequired: () => LocalizedString; + /** + * You need to be authenticated to access this application. + */ + signInRequiredMessage: () => LocalizedString; + /** + * Better Auth Demo + */ + betterAuthDemo: () => LocalizedString; + /** + * Welcome back! + */ + welcomeBack: () => LocalizedString; + /** + * Sign in to continue + */ + signInToContinue: () => LocalizedString; + /** + * You are signed in + */ + youAreSignedIn: () => LocalizedString; + /** + * User Information + */ + userInformation: () => LocalizedString; + /** + * Sign Out + */ + signOutButton: () => LocalizedString; + /** + * Or Email & Password + */ + orEmailPassword: () => LocalizedString; + /** + * Resend verification email + */ + resendVerificationEmail: () => LocalizedString; + /** + * Verification email sent! Check your inbox. + */ + verificationEmailSent: () => LocalizedString; + /** + * Min 8 characters with uppercase, lowercase, number, and special character. + */ + passwordMinChars: () => LocalizedString; + }; + Dashboard: { + /** + * Platform Tenants + */ + platformTenantsTitle: () => LocalizedString; + /** + * Create, edit, and remove tenants. Super-admin only. + */ + platformTenantsSubtitle: () => LocalizedString; + /** + * You must be logged in to access this page. + */ + mustBeLoggedIn: () => LocalizedString; + /** + * Super-admin access required. Your email must be in SUPER_ADMIN_EMAILS. + */ + superAdminRequired: () => LocalizedString; + /** + * Add tenant + */ + addTenant: () => LocalizedString; + /** + * Slug (a-z, 0-9, hyphens) + */ + slugLabel: () => LocalizedString; + /** + * e.g. acme-corp + */ + slugPlaceholder: () => LocalizedString; + /** + * e.g. Acme Corp + */ + namePlaceholder: () => LocalizedString; + /** + * {count} tenant{{s|}} + */ + tenantCount: (arg: { count: number }) => LocalizedString; + /** + * Loading tenants... + */ + loadingTenants: () => LocalizedString; + /** + * No tenants yet. Add one above. + */ + noTenantsYet: () => LocalizedString; + /** + * Refresh list + */ + refreshList: () => LocalizedString; + /** + * Delete tenant "{slug}"? This cannot be undone. + */ + deleteConfirm: (arg: { slug: string }) => LocalizedString; + /** + * Dual Database CRUD Demo + */ + dualDatabaseCrudDemo: () => LocalizedString; + /** + * Side-by-side comparison of Mongoose (MongoDB) and Prisma (PostgreSQL) + */ + crudDemoSubtitle: () => LocalizedString; + /** + * NextJs (TailwindCSS) • NestJs • tRPC • Transactions + */ + crudDemoTechStack: () => LocalizedString; + /** + * → Tenant Dashboard (super-admin) + */ + tenantDashboardLink: () => LocalizedString; + /** + * Global CRUD Demo + */ + globalCrudDemoTitle: () => LocalizedString; + /** + * Same as CRUD but shared across all tenants. Everyone sees and edits the same data. + */ + globalCrudDemoSubtitle: () => LocalizedString; + /** + * Mongoose (MongoDB) & Prisma (PostgreSQL) • No tenant scope + */ + globalCrudDemoTechStack: () => LocalizedString; + /** + * Add text here + */ + addTextPlaceholder: () => LocalizedString; + }; + Settings: { + /** + * Profile + */ + profileTitle: () => LocalizedString; + /** + * Manage your account security and linked sign-in methods. + */ + profileSubtitle: () => LocalizedString; + /** + * Your email is not verified + */ + emailNotVerified: () => LocalizedString; + /** + * Please verify your email to access all features. + */ + verifyEmailPrompt: () => LocalizedString; + /** + * Account Info + */ + accountInfo: () => LocalizedString; + /** + * Linked Accounts + */ + linkedAccounts: () => LocalizedString; + /** + * Google + */ + google: () => LocalizedString; + /** + * Connected + */ + connected: () => LocalizedString; + /** + * Connect Google + */ + connectGoogle: () => LocalizedString; + /** + * Email & Password + */ + emailAndPassword: () => LocalizedString; + /** + * Password set + */ + passwordSet: () => LocalizedString; + /** + * Not set + */ + notSet: () => LocalizedString; + /** + * Password + */ + passwordTitle: () => LocalizedString; + /** + * Change Password + */ + changePassword: () => LocalizedString; + /** + * Current Password + */ + currentPassword: () => LocalizedString; + /** + * New Password (min 8 characters) + */ + newPasswordLabel: () => LocalizedString; + /** + * Confirm New Password + */ + confirmNewPassword: () => LocalizedString; + /** + * Save Password + */ + savePassword: () => LocalizedString; + /** + * Add a password to sign in without Google or OTP. + */ + addPasswordDescription: () => LocalizedString; + /** + * Check your inbox to set your password. + */ + checkInboxForPassword: () => LocalizedString; + /** + * Manage Members + */ + manageMembersTitle: () => LocalizedString; + /** + * Add or remove members from your tenants. + */ + manageMembersSubtitle: () => LocalizedString; + /** + * No admin access + */ + noAdminAccess: () => LocalizedString; + /** + * You don't have admin access to any tenants. + */ + noAdminAccessMessage: () => LocalizedString; + /** + * Your tenants ({count}) + */ + yourTenants: (arg: { count: number }) => LocalizedString; + /** + * Members of {name} + */ + membersOf: (arg: { name: string }) => LocalizedString; + /** + * Add member + */ + addMember: () => LocalizedString; + /** + * user@example.com + */ + memberEmailPlaceholder: () => LocalizedString; + /** + * Loading members... + */ + loadingMembers: () => LocalizedString; + /** + * No members yet. Add one above. + */ + noMembersYet: () => LocalizedString; + /** + * Remove member + */ + removeMember: () => LocalizedString; + /** + * Select a tenant from the list to manage its members. + */ + selectTenantPrompt: () => LocalizedString; + }; + Forms: { + /** + * Email + */ + email: () => LocalizedString; + /** + * Email Address + */ + emailAddress: () => LocalizedString; + /** + * you@example.com + */ + emailPlaceholder: () => LocalizedString; + /** + * Enter your email + */ + enterYourEmail: () => LocalizedString; + /** + * Password + */ + password: () => LocalizedString; + /** + * •••••••• + */ + passwordPlaceholder: () => LocalizedString; + /** + * Password (min 8 characters) + */ + passwordMinChars: () => LocalizedString; + /** + * Confirm Password + */ + confirmPassword: () => LocalizedString; + /** + * First Name + */ + firstName: () => LocalizedString; + /** + * John + */ + firstNamePlaceholder: () => LocalizedString; + /** + * Last Name + */ + lastName: () => LocalizedString; + /** + * Doe + */ + lastNamePlaceholder: () => LocalizedString; + /** + * Name + */ + name: () => LocalizedString; + /** + * Your name + */ + namePlaceholder: () => LocalizedString; + /** + * Verification Code + */ + verificationCode: () => LocalizedString; + /** + * Enter 6-digit code + */ + verificationCodePlaceholder: () => LocalizedString; + /** + * Slug + */ + slug: () => LocalizedString; + }; + Errors: { + /** + * Failed to sign in with Google + */ + failedSignInGoogle: () => LocalizedString; + /** + * Failed to send OTP. Please try again. + */ + failedSendOtp: () => LocalizedString; + /** + * Unable to send OTP + */ + unableToSendOtp: () => LocalizedString; + /** + * Invalid OTP. Please try again. + */ + invalidOtp: () => LocalizedString; + /** + * Unable to verify OTP + */ + unableToVerifyOtp: () => LocalizedString; + /** + * Please verify your email address before signing in. + */ + verifyEmailBeforeSignIn: () => LocalizedString; + /** + * This account was created with Google or OTP. Use 'Forgot Password' below to set a password. + */ + accountCreatedWithSocial: () => LocalizedString; + /** + * Invalid email or password. + */ + invalidEmailOrPassword: () => LocalizedString; + /** + * Passwords do not match. + */ + passwordsDoNotMatch: () => LocalizedString; + /** + * Password must be at least 8 characters. + */ + passwordMinLength: () => LocalizedString; + /** + * Password must be at most 128 characters. + */ + passwordMaxLength: () => LocalizedString; + /** + * Password must contain at least one uppercase letter. + */ + passwordUppercase: () => LocalizedString; + /** + * Password must contain at least one lowercase letter. + */ + passwordLowercase: () => LocalizedString; + /** + * Password must contain at least one number. + */ + passwordNumber: () => LocalizedString; + /** + * Password must contain at least one special character. + */ + passwordSpecialChar: () => LocalizedString; + /** + * Failed to reset password. Please try again. + */ + failedResetPassword: () => LocalizedString; + /** + * Failed to change password. + */ + failedChangePassword: () => LocalizedString; + /** + * Password changed successfully. + */ + passwordChangedSuccess: () => LocalizedString; + /** + * Sign up failed. Please try again. + */ + signUpFailed: () => LocalizedString; + /** + * Sign in failed + */ + signInFailed: () => LocalizedString; + /** + * An account with this email already exists. Try signing in instead — if you used Google or OTP, you can set a password from your profile. + */ + accountAlreadyExists: () => LocalizedString; + /** + * {label} is required. + */ + nameRequired: (arg: { label: string }) => LocalizedString; + /** + * {label} must be at least 2 characters. + */ + nameMinLength: (arg: { label: string }) => LocalizedString; + /** + * {label} can only contain letters, spaces, hyphens, and apostrophes. + */ + nameInvalidChars: (arg: { label: string }) => LocalizedString; + /** + * Email is required. + */ + emailRequired: () => LocalizedString; + /** + * Please enter a valid email address. + */ + emailInvalid: () => LocalizedString; + /** + * Password is required. + */ + passwordRequired: () => LocalizedString; + /** + * Slug is required + */ + slugRequired: () => LocalizedString; + /** + * Name is required + */ + nameFieldRequired: () => LocalizedString; + /** + * Slug must be lowercase letters, numbers, and hyphens only + */ + slugInvalid: () => LocalizedString; + /** + * No tenants assigned + */ + noTenantsAssigned: () => LocalizedString; + /** + * You don't have access to any tenants yet. Please contact an administrator to get added to a tenant. + */ + noTenantsMessage: () => LocalizedString; + /** + * Loading your tenants... + */ + loadingYourTenants: () => LocalizedString; + /** + * Setting up your tenant... + */ + settingUpTenant: () => LocalizedString; + }; +}; + +export type Formatters = {}; diff --git a/apps/web/i18n/i18n-util.async.ts b/apps/web/i18n/i18n-util.async.ts new file mode 100644 index 0000000..c32d679 --- /dev/null +++ b/apps/web/i18n/i18n-util.async.ts @@ -0,0 +1,32 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. + +import { initFormatters } from "./formatters"; +import type { Locales, Translations } from "./i18n-types"; +import { loadedFormatters, loadedLocales, locales } from "./i18n-util"; + +const localeTranslationLoaders = { + en: () => import("./en"), + nl: () => import("./nl"), +}; + +const updateDictionary = ( + locale: Locales, + dictionary: Partial, +): Translations => + (loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }); + +export const importLocaleAsync = async ( + locale: Locales, +): Promise => + (await localeTranslationLoaders[locale]()).default as unknown as Translations; + +export const loadLocaleAsync = async (locale: Locales): Promise => { + updateDictionary(locale, await importLocaleAsync(locale)); + loadFormatters(locale); +}; + +export const loadAllLocalesAsync = (): Promise => + Promise.all(locales.map(loadLocaleAsync)); + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)); diff --git a/apps/web/i18n/i18n-util.sync.ts b/apps/web/i18n/i18n-util.sync.ts new file mode 100644 index 0000000..0048d21 --- /dev/null +++ b/apps/web/i18n/i18n-util.sync.ts @@ -0,0 +1,25 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. + +import { initFormatters } from "./formatters"; +import type { Locales, Translations } from "./i18n-types"; +import { loadedFormatters, loadedLocales, locales } from "./i18n-util"; + +import en from "./en"; +import nl from "./nl"; + +const localeTranslations = { + en, + nl, +}; + +export const loadLocale = (locale: Locales): void => { + if (loadedLocales[locale]) return; + + loadedLocales[locale] = localeTranslations[locale] as unknown as Translations; + loadFormatters(locale); +}; + +export const loadAllLocales = (): void => locales.forEach(loadLocale); + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)); diff --git a/apps/web/i18n/i18n-util.ts b/apps/web/i18n/i18n-util.ts new file mode 100644 index 0000000..4b15f60 --- /dev/null +++ b/apps/web/i18n/i18n-util.ts @@ -0,0 +1,62 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. + +import { + i18n as initI18n, + i18nObject as initI18nObject, + i18nString as initI18nString, +} from "typesafe-i18n"; +import type { LocaleDetector } from "typesafe-i18n/detectors"; +import type { + LocaleTranslationFunctions, + TranslateByString, +} from "typesafe-i18n"; +import { detectLocale as detectLocaleFn } from "typesafe-i18n/detectors"; +import { initExtendDictionary } from "typesafe-i18n/utils"; +import type { + Formatters, + Locales, + Translations, + TranslationFunctions, +} from "./i18n-types"; + +export const baseLocale: Locales = "en"; + +export const locales: Locales[] = ["en", "nl"]; + +export const isLocale = (locale: string): locale is Locales => + locales.includes(locale as Locales); + +export const loadedLocales: Record = {} as Record< + Locales, + Translations +>; + +export const loadedFormatters: Record = {} as Record< + Locales, + Formatters +>; + +export const extendDictionary = initExtendDictionary(); + +export const i18nString = (locale: Locales): TranslateByString => + initI18nString(locale, loadedFormatters[locale]); + +export const i18nObject = (locale: Locales): TranslationFunctions => + initI18nObject( + locale, + loadedLocales[locale], + loadedFormatters[locale], + ); + +export const i18n = (): LocaleTranslationFunctions< + Locales, + Translations, + TranslationFunctions +> => + initI18n( + loadedLocales, + loadedFormatters, + ); + +export const detectLocale = (...detectors: LocaleDetector[]): Locales => + detectLocaleFn(baseLocale, locales, ...detectors); diff --git a/apps/web/i18n/nl/index.ts b/apps/web/i18n/nl/index.ts new file mode 100644 index 0000000..8c00cb6 --- /dev/null +++ b/apps/web/i18n/nl/index.ts @@ -0,0 +1,257 @@ +import type { Translation } from "../i18n-types"; + +const nl = { + Common: { + loading: "Laden...", + backToHome: "Terug naar Home", + back: "Terug", + cancel: "Annuleren", + save: "Opslaan", + add: "Toevoegen", + refresh: "Vernieuwen", + refreshing: "Vernieuwen...", + adding: "Toevoegen...", + saving: "Opslaan...", + sending: "Verzenden...", + or: "Of", + roleAdmin: "Beheerder", + roleUser: "Gebruiker", + you: "Jij", + edit: "Bewerken", + delete: "Verwijderen", + loadingItems: "Items laden...", + noItemsYet: "Nog geen items. Voeg er een toe om te beginnen!", + itemCount: "{count} {{Item|Items}}", + itemCountGlobal: "{count} {{Item|Items}} (globaal)", + }, + Navigation: { + fullStack: "Full Stack", + profileAndSecurity: "Profiel & Beveiliging", + platformTenants: "Platform Tenants", + manageMembers: "Leden beheren", + signOut: "Uitloggen", + switchTenant: "Wissel van tenant", + switching: "Wisselen...", + selectTenant: "Selecteer tenant", + }, + Home: { + title: "Full Stack Boilerplate", + subtitle: "NextJs (Tailwind CSS), NestJs, Expo, tRPC, Better Auth", + crudDemoTitle: "CRUD Demo", + crudDemoDescription: + "Test CRUD-operaties met tRPC en Prisma-integratie. Volledige full-stack typeveiligheid demonstratie.", + globalCrudDemoTitle: "Globale CRUD Demo", + globalCrudDemoDescription: + "Hetzelfde als CRUD maar gedeeld over alle tenants. Iedereen ziet en bewerkt dezelfde gegevens.", + platformTenantsTitle: "Platform Tenants", + platformTenantsDescription: + "Maak, bewerk en verwijder tenants op het platform. Alleen voor super-admin.", + manageTenants: "Tenants beheren", + manageMembersTitle: "Leden beheren", + manageMembersDescription: + "Voeg leden toe of verwijder ze uit tenants die je beheert.", + manageMembers: "Leden beheren", + authDemoTitle: "Auth Demo", + authDemoDescription: + "Test authenticatie met Better Auth. OAuth-integratie met Google, sessiebeheer en beveiligde routes.", + tryItOut: "Probeer het", + footer: "Gebouwd met moderne tools voor snelle ontwikkeling", + }, + Auth: { + signIn: "Inloggen", + signInTitle: "Inloggen", + signInSubtitle: "Kies je favoriete inlogmethode", + signInWithGoogle: "Inloggen met Google", + signInWithEmailOtp: "Inloggen met e-mail OTP", + orWithEmailPassword: "Of met e-mail & wachtwoord", + signInWithEmailPassword: "Inloggen met e-mail & wachtwoord", + signingIn: "Inloggen...", + signUp: "Registreren", + signUpTitle: "Account aanmaken", + signUpSubtitle: "Registreer met je e-mailadres en wachtwoord", + signUpWithEmailPassword: "Registreren met e-mail & wachtwoord", + creatingAccount: "Account aanmaken...", + dontHaveAccount: "Heb je nog geen account?", + alreadyHaveAccount: "Heb je al een account?", + forgotPassword: "Wachtwoord vergeten?", + forgotPasswordTitle: "Wachtwoord vergeten", + forgotPasswordSubtitle: + "Voer je e-mailadres in om een wachtwoord-resetlink te ontvangen", + checkYourInbox: "Controleer je inbox", + sendResetLink: "Resetlink verzenden", + backToSignIn: "Terug naar inloggen", + resetLinkSentMessage: + "Als er een account bestaat met dit e-mailadres, ontvang je een link om je wachtwoord in te stellen.", + setYourPassword: "Stel je wachtwoord in", + setPasswordSubtitle: "Voer een nieuw wachtwoord in voor je account", + setPassword: "Wachtwoord instellen", + settingPassword: "Wachtwoord instellen...", + invalidOrExpiredLink: "Ongeldige of verlopen link", + invalidOrExpiredLinkMessage: + "Deze wachtwoord-resetlink is ongeldig of verlopen. Vraag een nieuwe aan.", + requestNewLink: "Nieuwe link aanvragen", + verificationFailed: "Verificatie mislukt", + verificationFailedExpired: + "Deze verificatielink is verlopen. Probeer opnieuw in te loggen om een nieuwe verificatie-e-mail te ontvangen.", + verificationFailedInvalid: + "Deze verificatielink is ongeldig. Probeer opnieuw in te loggen om een nieuwe verificatie-e-mail te ontvangen.", + emailVerified: "E-mail geverifieerd!", + emailVerifiedMessage: + "Je e-mailadres is succesvol geverifieerd. Je wordt doorgestuurd naar de homepage...", + goToHome: "Ga nu naar Home", + goToSignIn: "Ga naar inloggen", + sendOtp: "OTP verzenden", + verifyOtp: "OTP verifiëren", + verifying: "Verifiëren...", + changeEmail: "E-mail wijzigen", + otpSentTo: "We hebben een verificatiecode gestuurd naar {email}", + checkInboxMessage: + "We hebben een verificatie-e-mail gestuurd naar {email}. Controleer je inbox en klik op de link om je account te verifiëren voordat je inlogt.", + passwordSetSuccess: "Wachtwoord succesvol ingesteld. Je kunt nu inloggen.", + signInRequired: "Inloggen vereist", + signInRequiredMessage: + "Je moet ingelogd zijn om deze applicatie te gebruiken.", + betterAuthDemo: "Better Auth Demo", + welcomeBack: "Welkom terug!", + signInToContinue: "Log in om verder te gaan", + youAreSignedIn: "Je bent ingelogd", + userInformation: "Gebruikersinformatie", + signOutButton: "Uitloggen", + orEmailPassword: "Of e-mail & wachtwoord", + resendVerificationEmail: "Verificatie-e-mail opnieuw verzenden", + verificationEmailSent: "Verificatie-e-mail verzonden! Controleer je inbox.", + passwordMinChars: + "Minimaal 8 tekens met hoofdletter, kleine letter, cijfer en speciaal teken.", + }, + Dashboard: { + platformTenantsTitle: "Platform Tenants", + platformTenantsSubtitle: + "Maak, bewerk en verwijder tenants. Alleen voor super-admin.", + mustBeLoggedIn: "Je moet ingelogd zijn om deze pagina te openen.", + superAdminRequired: + "Super-admin toegang vereist. Je e-mailadres moet in SUPER_ADMIN_EMAILS staan.", + addTenant: "Tenant toevoegen", + slugLabel: "Slug (a-z, 0-9, koppeltekens)", + slugPlaceholder: "bijv. acme-corp", + namePlaceholder: "bijv. Acme Corp", + tenantCount: "{count} tenant{{s|}}", + loadingTenants: "Tenants laden...", + noTenantsYet: "Nog geen tenants. Voeg er hierboven een toe.", + refreshList: "Lijst vernieuwen", + deleteConfirm: + 'Tenant "{slug}" verwijderen? Dit kan niet ongedaan worden gemaakt.', + dualDatabaseCrudDemo: "Dubbele Database CRUD Demo", + crudDemoSubtitle: + "Vergelijking naast elkaar van Mongoose (MongoDB) en Prisma (PostgreSQL)", + crudDemoTechStack: + "NextJs (TailwindCSS) \u2022 NestJs \u2022 tRPC \u2022 Transacties", + tenantDashboardLink: "\u2192 Tenant Dashboard (super-admin)", + globalCrudDemoTitle: "Globale CRUD Demo", + globalCrudDemoSubtitle: + "Hetzelfde als CRUD maar gedeeld over alle tenants. Iedereen ziet en bewerkt dezelfde gegevens.", + globalCrudDemoTechStack: + "Mongoose (MongoDB) & Prisma (PostgreSQL) \u2022 Geen tenant scope", + addTextPlaceholder: "Voeg hier tekst toe", + }, + Settings: { + profileTitle: "Profiel", + profileSubtitle: + "Beheer je accountbeveiliging en gekoppelde inlogmethoden.", + emailNotVerified: "Je e-mail is niet geverifieerd", + verifyEmailPrompt: "Verifieer je e-mail om alle functies te gebruiken.", + accountInfo: "Accountinformatie", + linkedAccounts: "Gekoppelde accounts", + google: "Google", + connected: "Verbonden", + connectGoogle: "Google koppelen", + emailAndPassword: "E-mail & wachtwoord", + passwordSet: "Wachtwoord ingesteld", + notSet: "Niet ingesteld", + passwordTitle: "Wachtwoord", + changePassword: "Wachtwoord wijzigen", + currentPassword: "Huidig wachtwoord", + newPasswordLabel: "Nieuw wachtwoord (min. 8 tekens)", + confirmNewPassword: "Bevestig nieuw wachtwoord", + savePassword: "Wachtwoord opslaan", + addPasswordDescription: + "Voeg een wachtwoord toe om in te loggen zonder Google of OTP.", + checkInboxForPassword: + "Controleer je inbox om je wachtwoord in te stellen.", + manageMembersTitle: "Leden beheren", + manageMembersSubtitle: "Voeg leden toe of verwijder ze uit je tenants.", + noAdminAccess: "Geen beheerderstoegang", + noAdminAccessMessage: "Je hebt geen beheerderstoegang tot tenants.", + yourTenants: "Je tenants ({count})", + membersOf: "Leden van {name}", + addMember: "Lid toevoegen", + memberEmailPlaceholder: "gebruiker@voorbeeld.nl", + loadingMembers: "Leden laden...", + noMembersYet: "Nog geen leden. Voeg er hierboven een toe.", + removeMember: "Lid verwijderen", + selectTenantPrompt: + "Selecteer een tenant uit de lijst om de leden te beheren.", + }, + Forms: { + email: "E-mail", + emailAddress: "E-mailadres", + emailPlaceholder: "jij@voorbeeld.nl", + enterYourEmail: "Voer je e-mailadres in", + password: "Wachtwoord", + passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", + passwordMinChars: "Wachtwoord (min. 8 tekens)", + confirmPassword: "Bevestig wachtwoord", + firstName: "Voornaam", + firstNamePlaceholder: "Jan", + lastName: "Achternaam", + lastNamePlaceholder: "Jansen", + name: "Naam", + namePlaceholder: "Je naam", + verificationCode: "Verificatiecode", + verificationCodePlaceholder: "Voer 6-cijferige code in", + slug: "Slug", + }, + Errors: { + failedSignInGoogle: "Inloggen met Google mislukt", + failedSendOtp: "OTP verzenden mislukt. Probeer het opnieuw.", + unableToSendOtp: "Kan OTP niet verzenden", + invalidOtp: "Ongeldige OTP. Probeer het opnieuw.", + unableToVerifyOtp: "Kan OTP niet verifiëren", + verifyEmailBeforeSignIn: "Verifieer je e-mailadres voordat je inlogt.", + accountCreatedWithSocial: + "Dit account is aangemaakt met Google of OTP. Gebruik 'Wachtwoord vergeten' hieronder om een wachtwoord in te stellen.", + invalidEmailOrPassword: "Ongeldig e-mailadres of wachtwoord.", + passwordsDoNotMatch: "Wachtwoorden komen niet overeen.", + passwordMinLength: "Wachtwoord moet minimaal 8 tekens bevatten.", + passwordMaxLength: "Wachtwoord mag maximaal 128 tekens bevatten.", + passwordUppercase: "Wachtwoord moet minstens één hoofdletter bevatten.", + passwordLowercase: "Wachtwoord moet minstens één kleine letter bevatten.", + passwordNumber: "Wachtwoord moet minstens één cijfer bevatten.", + passwordSpecialChar: + "Wachtwoord moet minstens één speciaal teken bevatten.", + failedResetPassword: "Wachtwoord resetten mislukt. Probeer het opnieuw.", + failedChangePassword: "Wachtwoord wijzigen mislukt.", + passwordChangedSuccess: "Wachtwoord succesvol gewijzigd.", + signUpFailed: "Registratie mislukt. Probeer het opnieuw.", + signInFailed: "Inloggen mislukt", + accountAlreadyExists: + "Er bestaat al een account met dit e-mailadres. Probeer in te loggen \u2014 als je Google of OTP hebt gebruikt, kun je een wachtwoord instellen vanuit je profiel.", + nameRequired: "{label} is verplicht.", + nameMinLength: "{label} moet minimaal 2 tekens bevatten.", + nameInvalidChars: + "{label} mag alleen letters, spaties, koppeltekens en apostrofs bevatten.", + emailRequired: "E-mail is verplicht.", + emailInvalid: "Voer een geldig e-mailadres in.", + passwordRequired: "Wachtwoord is verplicht.", + slugRequired: "Slug is verplicht", + nameFieldRequired: "Naam is verplicht", + slugInvalid: + "Slug mag alleen kleine letters, cijfers en koppeltekens bevatten", + noTenantsAssigned: "Geen tenants toegewezen", + noTenantsMessage: + "Je hebt nog geen toegang tot tenants. Neem contact op met een beheerder om aan een tenant te worden toegevoegd.", + loadingYourTenants: "Je tenants laden...", + settingUpTenant: "Je tenant instellen...", + }, +} satisfies Translation; + +export default nl; diff --git a/apps/web/package.json b/apps/web/package.json index 0299c75..b1d3e9a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,12 +9,13 @@ "start": "dotenvx run -- next start", "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "i18n:generate": "typesafe-i18n" }, "dependencies": { "@repo/trpc": "workspace:*", - "@repo/utils-core": "workspace:*", "@repo/ui": "workspace:*", + "@repo/utils-core": "workspace:*", "@tanstack/react-query": "5.76.1", "@trpc/react-query": "11.8.1", "next": "^16.1.4", @@ -33,6 +34,7 @@ "eslint-config-next": "^15.5.6", "postcss": "^8.5.6", "tailwindcss": "^4.1.14", + "typesafe-i18n": "^5.27.1", "typescript": "5.9.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28779cc..24af632 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,9 @@ importers: tailwindcss: specifier: ^4.1.14 version: 4.1.18 + typesafe-i18n: + specifier: ^5.27.1 + version: 5.27.1(typescript@5.9.2) typescript: specifier: 5.9.2 version: 5.9.2 @@ -7507,6 +7510,12 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typesafe-i18n@5.27.1: + resolution: {integrity: sha512-749uWo2ZXETT//kWjVYPm8QPYR8xLh8G0wLfoAyCAtAmysX67uCaAyLjAjAWojL6fuJpE5B6yIjwvO9orXzUPg==} + hasBin: true + peerDependencies: + typescript: '>=3.5.1' + typescript-eslint@8.50.1: resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -16416,6 +16425,10 @@ snapshots: typedarray@0.0.6: {} + typesafe-i18n@5.27.1(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2): dependencies: '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2)