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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/.typesafe-i18n.json
Original file line number Diff line number Diff line change
@@ -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"
}
22 changes: 22 additions & 0 deletions apps/web/I18N.md
Original file line number Diff line number Diff line change
@@ -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 <h1>{LL.Home.title()}</h1>;
}
```

- **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/<locale>/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`.
97 changes: 50 additions & 47 deletions apps/web/app/auth-demo/AuthDemoClient.tsx

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion apps/web/app/auth-demo/AuthDemoLoadingShell.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
"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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<div
className="mx-auto h-12 w-12 animate-spin rounded-full border-2 border-b-gray-900 border-transparent"
aria-hidden
/>
<p className="mt-4 text-gray-600">Loading...</p>
<p className="mt-4 text-gray-600">{LL.Common.loading()}</p>
</div>
</div>
);
Expand Down
85 changes: 85 additions & 0 deletions apps/web/app/components/LanguageSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<Locales, string> = {
en: "English",
nl: "Nederlands",
};

export function LanguageSelector() {
const { locale, setLocale } = useLocaleContext();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 bg-slate-700 hover:bg-slate-600 border border-slate-600 rounded-lg px-2.5 py-1.5 text-sm text-white transition-colors"
>
<svg
className="w-4 h-4 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
<span className="max-w-[80px] truncate">{localeLabels[locale]}</span>
<svg
className={`w-3 h-3 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>

{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-40 bg-slate-800 border border-slate-600 rounded-lg shadow-xl overflow-hidden">
{locales.map((l) => (
<button
key={l}
onClick={() => {
setLocale(l);
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
l === locale
? "bg-blue-500/10 text-blue-400"
: "text-slate-300 hover:bg-slate-700"
}`}
>
{localeLabels[l]}
</button>
))}
</div>
)}
</div>
);
}
20 changes: 9 additions & 11 deletions apps/web/app/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AuthGate>
<TenantGate>
<NavHeader />
<div className="pt-14">{children}</div>
</TenantGate>
</AuthGate>
<LocaleProvider>
<NavHeader />
<div className="pt-14">
<AuthGate>
<TenantGate>{children}</TenantGate>
</AuthGate>
</div>
</LocaleProvider>
);
}
21 changes: 9 additions & 12 deletions apps/web/app/components/auth-gate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
Expand Down Expand Up @@ -51,35 +46,37 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
}

function AuthGateLoading() {
const { LL } = useI18n();
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="text-center">
<div
className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-b-blue-400 border-transparent"
aria-hidden
/>
<p className="mt-4 text-slate-400 text-sm">Loading...</p>
<p className="mt-4 text-slate-400 text-sm">{LL.Common.loading()}</p>
</div>
</div>
);
}

function AuthGateLogin() {
const { LL } = useI18n();
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8">
<div className="max-w-md w-full bg-slate-800 border border-slate-700 rounded-xl p-10 text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-gradient-to-r from-blue-400 to-cyan-400 flex items-center justify-center">
<span className="text-slate-900 font-bold text-xl">BE</span>
</div>
<h1 className="text-2xl font-bold text-white mb-3">Sign in required</h1>
<p className="text-slate-400 mb-8">
You need to be authenticated to access this application.
</p>
<h1 className="text-2xl font-bold text-white mb-3">
{LL.Auth.signInRequired()}
</h1>
<p className="text-slate-400 mb-8">{LL.Auth.signInRequiredMessage()}</p>
<a
href="/sign-in"
className="inline-block bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 px-8 py-3 rounded-lg text-white font-semibold transition-all"
>
Sign in
{LL.Auth.signIn()}
</a>
</div>
</div>
Expand Down
Loading