diff --git a/frontend/app/can-sr/citations/full-text/route.ts b/frontend/app/[lang]/can-sr/citations/full-text/route.ts similarity index 100% rename from frontend/app/can-sr/citations/full-text/route.ts rename to frontend/app/[lang]/can-sr/citations/full-text/route.ts diff --git a/frontend/app/can-sr/extract/page.tsx b/frontend/app/[lang]/can-sr/extract/page.tsx similarity index 100% rename from frontend/app/can-sr/extract/page.tsx rename to frontend/app/[lang]/can-sr/extract/page.tsx diff --git a/frontend/app/can-sr/extract/view/page.tsx b/frontend/app/[lang]/can-sr/extract/view/page.tsx similarity index 100% rename from frontend/app/can-sr/extract/view/page.tsx rename to frontend/app/[lang]/can-sr/extract/view/page.tsx diff --git a/frontend/app/can-sr/l1-screen/page.tsx b/frontend/app/[lang]/can-sr/l1-screen/page.tsx similarity index 100% rename from frontend/app/can-sr/l1-screen/page.tsx rename to frontend/app/[lang]/can-sr/l1-screen/page.tsx diff --git a/frontend/app/can-sr/l1-screen/view/page.tsx b/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx similarity index 100% rename from frontend/app/can-sr/l1-screen/view/page.tsx rename to frontend/app/[lang]/can-sr/l1-screen/view/page.tsx diff --git a/frontend/app/can-sr/l2-screen/page.tsx b/frontend/app/[lang]/can-sr/l2-screen/page.tsx similarity index 100% rename from frontend/app/can-sr/l2-screen/page.tsx rename to frontend/app/[lang]/can-sr/l2-screen/page.tsx diff --git a/frontend/app/can-sr/l2-screen/view/page.tsx b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx similarity index 100% rename from frontend/app/can-sr/l2-screen/view/page.tsx rename to frontend/app/[lang]/can-sr/l2-screen/view/page.tsx diff --git a/frontend/app/can-sr/page.tsx b/frontend/app/[lang]/can-sr/page.tsx similarity index 100% rename from frontend/app/can-sr/page.tsx rename to frontend/app/[lang]/can-sr/page.tsx diff --git a/frontend/app/can-sr/search/page.tsx b/frontend/app/[lang]/can-sr/search/page.tsx similarity index 100% rename from frontend/app/can-sr/search/page.tsx rename to frontend/app/[lang]/can-sr/search/page.tsx diff --git a/frontend/app/can-sr/setup/page.tsx b/frontend/app/[lang]/can-sr/setup/page.tsx similarity index 100% rename from frontend/app/can-sr/setup/page.tsx rename to frontend/app/[lang]/can-sr/setup/page.tsx diff --git a/frontend/app/can-sr/sr/page.tsx b/frontend/app/[lang]/can-sr/sr/page.tsx similarity index 100% rename from frontend/app/can-sr/sr/page.tsx rename to frontend/app/[lang]/can-sr/sr/page.tsx diff --git a/frontend/app/[lang]/dictionaries.ts b/frontend/app/[lang]/dictionaries.ts new file mode 100644 index 00000000..f1ce62e1 --- /dev/null +++ b/frontend/app/[lang]/dictionaries.ts @@ -0,0 +1,46 @@ +// Might want to add server-only to dependencies +// import 'server-only' + +const dictionaries = { + en: () => import('@/dictionaries/en.json').then((module) => module.default), + fr: () => import('@/dictionaries/fr.json').then((module) => module.default), +} + +// Fill missing translations of primary with its key by comparing with reference +// path is the recursively generated key +function fillWithKey( + primary: any, + reference: any, + path: string[] = [] +): any { + // Reach end of JSON (past leaf) + if (typeof reference !== 'object' || reference === null) { + return primary ?? path.join('.'); + } + + // Result dictionary + const result: any = {}; + + // Iterate through list of keys from the reference dictionary + for (const key of Object.keys(reference)) { + // Recursive call of subproperties (if any) + result[key] = fillWithKey( + primary?.[key], + reference[key], + [...path, key] + ); + } + + return result; +} + + +export const getDictionary = async (locale: 'en' | 'fr') => { + const dict = await dictionaries[locale](); + + if (locale === 'en') return dict; + + // Compare and fill with keys if French + const reference = await dictionaries.en(); + return fillWithKey(dict, reference); +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/[lang]/layout.tsx similarity index 100% rename from frontend/app/layout.tsx rename to frontend/app/[lang]/layout.tsx diff --git a/frontend/app/login/page.tsx b/frontend/app/[lang]/login/page.tsx similarity index 95% rename from frontend/app/login/page.tsx rename to frontend/app/[lang]/login/page.tsx index ffa19530..e307a601 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/[lang]/login/page.tsx @@ -9,8 +9,13 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Eye, EyeOff } from 'lucide-react' import { API_ENDPOINTS } from '@/lib/config' +import { getDictionary } from '../dictionaries' -export default function LoginPage() { +export default async function LoginPage({ + params, +}: { + params: Promise<{ lang: 'en' | 'fr' }> +}) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -68,6 +73,11 @@ export default function LoginPage() { } } + // Get language dictionary + const { lang } = await params; + const dict = await getDictionary(lang) + console.log(dict.login.formTitle) + return (
{/* Left side - Health Canada Image (reduced overlay opacity) */} @@ -87,7 +97,7 @@ export default function LoginPage() {

- Access the Government of Canada AI Assistant Portal + { dict.login.formTitle }

diff --git a/frontend/app/page.tsx b/frontend/app/[lang]/page.tsx similarity index 100% rename from frontend/app/page.tsx rename to frontend/app/[lang]/page.tsx diff --git a/frontend/app/register/page.tsx b/frontend/app/[lang]/register/page.tsx similarity index 100% rename from frontend/app/register/page.tsx rename to frontend/app/[lang]/register/page.tsx diff --git a/frontend/app/sso-login/page.tsx b/frontend/app/[lang]/sso-login/page.tsx similarity index 100% rename from frontend/app/sso-login/page.tsx rename to frontend/app/[lang]/sso-login/page.tsx diff --git a/frontend/dictionaries/en.json b/frontend/dictionaries/en.json new file mode 100644 index 00000000..9e1a37e7 --- /dev/null +++ b/frontend/dictionaries/en.json @@ -0,0 +1,5 @@ +{ + "login": { + "formTitle": "Access the Government of Canada AI Assistant Portal" + } +} \ No newline at end of file diff --git a/frontend/dictionaries/fr.json b/frontend/dictionaries/fr.json new file mode 100644 index 00000000..7a47a8c0 --- /dev/null +++ b/frontend/dictionaries/fr.json @@ -0,0 +1,5 @@ +{ + "login": { + "formTitle": "Accédez au portail de l'assistant IA du gouvernement du Canada" + } +} \ No newline at end of file diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..9eb40ddd --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,47 @@ +import { NextResponse, NextRequest } from "next/server"; +import { match } from "@formatjs/intl-localematcher"; +import Negotiator from "negotiator"; + +const locales = ["en", "fr"]; +const defaultLocale = "en" + +function getLocale(request: NextRequest) { + // Get Accept-Language header + const headers = { "accept-language": request.headers.get("accept-language") ?? "" }; + // Get requested locales (sorted in decreasing preference) + const requestedLocales = new Negotiator({ headers }).languages(); + + // Match to en or fr + return match(requestedLocales, locales, defaultLocale); +} + +export function middleware(request: NextRequest) { + // Check if there is any supported locale in the pathname + const { pathname } = request.nextUrl; + const pathnameHasLocale = locales.some( + (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` + ); + + if (pathnameHasLocale) return; + + // Redirect if there is no locale + const locale = getLocale(request); + request.nextUrl.pathname = `/${locale}${pathname}`; + // e.g. incoming request is /login + // The new URL is now /en/login + return NextResponse.redirect(request.nextUrl); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - images (static image assets from /public/images) + */ + '/((?!api|_next/static|_next/image|favicon.ico|images).*)', + ], +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b50f9aab..8a70854e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ai-sdk/openai": "^1.3.22", "@ai-sdk/react": "^1.2.12", + "@formatjs/intl-localematcher": "^0.8.0", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", @@ -31,6 +32,7 @@ "framer-motion": "^12.18.1", "katex": "^0.16.22", "lucide-react": "^0.515.0", + "negotiator": "^1.0.0", "next": "^15.2.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -47,6 +49,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/negotiator": "^0.6.4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -413,6 +416,25 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz", + "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.0.tgz", + "integrity": "sha512-zgMYWdUlmEZpX2Io+v3LHrfq9xZ6khpQVf9UAw2xYWhGerGgI9XgH1HvL/A34jWiruUJpYlP5pk4g8nIcaDrXQ==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.0", + "tslib": "^2.8.1" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2548,6 +2570,13 @@ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, + "node_modules/@types/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", @@ -7297,6 +7326,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "15.5.9", "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 10b41728..75ceb074 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "@ai-sdk/openai": "^1.3.22", "@ai-sdk/react": "^1.2.12", + "@formatjs/intl-localematcher": "^0.8.0", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", @@ -34,6 +35,7 @@ "framer-motion": "^12.18.1", "katex": "^0.16.22", "lucide-react": "^0.515.0", + "negotiator": "^1.0.0", "next": "^15.2.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -50,6 +52,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/negotiator": "^0.6.4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19",