From 847dfc5a82e369f6f88becaf1d0e0ca34df8a970 Mon Sep 17 00:00:00 2001 From: jessicanath Date: Sat, 27 Jun 2026 17:30:25 +0000 Subject: [PATCH] feat: add multilingual fallback and locale detection (#535) - middleware.ts: detect locale from Accept-Language header, persist as cookie, forward as x-locale header to server components; fallback to 'en' - lib/i18n.ts: message catalogue (en/es/fr/de/pt) + getTranslations() with English fallback for missing keys - layout.tsx: reads x-locale header, sets dynamically - api/locale/route.ts: POST endpoint to switch locale via cookie --- apps/web/src/app/api/locale/route.ts | 16 +++++ apps/web/src/app/layout.tsx | 12 +++- apps/web/src/lib/i18n.ts | 94 ++++++++++++++++++++++++++++ apps/web/src/middleware.ts | 48 ++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/locale/route.ts create mode 100644 apps/web/src/lib/i18n.ts create mode 100644 apps/web/src/middleware.ts diff --git a/apps/web/src/app/api/locale/route.ts b/apps/web/src/app/api/locale/route.ts new file mode 100644 index 0000000..179e9b3 --- /dev/null +++ b/apps/web/src/app/api/locale/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { SUPPORTED_LOCALES, type Locale } from '@/middleware' + +/** + * POST /api/locale { locale: "es" } + * Sets the locale cookie so subsequent requests use the chosen locale. + */ +export async function POST(req: NextRequest) { + const { locale } = await req.json().catch(() => ({})) + if (!SUPPORTED_LOCALES.includes(locale as Locale)) { + return NextResponse.json({ error: 'Unsupported locale' }, { status: 400 }) + } + const res = NextResponse.json({ locale }) + res.cookies.set('locale', locale as string, { path: '/', sameSite: 'lax', maxAge: 60 * 60 * 24 * 365 }) + return res +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 66fe893..1f8a4b3 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' +import { headers } from 'next/headers' import './globals.css' import { Providers } from './providers' import { Navbar } from '@/components/navbar' +import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from '@/middleware' const inter = Inter({ subsets: ['latin'] }) @@ -18,9 +20,15 @@ export const metadata: Metadata = { }, } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const headerList = await headers() + const rawLocale = headerList.get('x-locale') ?? DEFAULT_LOCALE + const locale: Locale = SUPPORTED_LOCALES.includes(rawLocale as Locale) + ? (rawLocale as Locale) + : DEFAULT_LOCALE + return ( - + diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts new file mode 100644 index 0000000..0ff26ab --- /dev/null +++ b/apps/web/src/lib/i18n.ts @@ -0,0 +1,94 @@ +import type { Locale } from '../middleware' + +// --------------------------------------------------------------------------- +// Message catalogue — add keys here, translate in each locale object +// --------------------------------------------------------------------------- +const messages = { + en: { + 'home.title': 'SolarProof', + 'home.tagline': 'End-to-end cryptographic proof of renewable energy on Stellar.', + 'home.subtitle': 'Every kWh signed at the meter · anchored on-chain · publicly verifiable', + 'home.verify_cta': 'Verify a Certificate', + 'home.dashboard_cta': 'Dashboard', + 'home.chain_title': 'Chain of custody', + 'nav.verify': 'Verify', + 'nav.dashboard': 'Dashboard', + 'verify.title': 'Verify Certificate', + 'verify.placeholder': 'Certificate ID or transaction hash', + 'verify.submit': 'Verify', + 'error.not_found': 'Not found', + 'error.invalid_signature': 'Invalid signature', + }, + es: { + 'home.title': 'SolarProof', + 'home.tagline': 'Prueba criptográfica de extremo a extremo de energía renovable en Stellar.', + 'home.subtitle': 'Cada kWh firmado en el medidor · anclado en cadena · verificable públicamente', + 'home.verify_cta': 'Verificar certificado', + 'home.dashboard_cta': 'Panel', + 'home.chain_title': 'Cadena de custodia', + 'nav.verify': 'Verificar', + 'nav.dashboard': 'Panel', + 'verify.title': 'Verificar certificado', + 'verify.placeholder': 'ID de certificado o hash de transacción', + 'verify.submit': 'Verificar', + 'error.not_found': 'No encontrado', + 'error.invalid_signature': 'Firma no válida', + }, + fr: { + 'home.title': 'SolarProof', + 'home.tagline': 'Preuve cryptographique de bout en bout de l\'énergie renouvelable sur Stellar.', + 'home.subtitle': 'Chaque kWh signé au compteur · ancré on-chain · vérifiable publiquement', + 'home.verify_cta': 'Vérifier un certificat', + 'home.dashboard_cta': 'Tableau de bord', + 'home.chain_title': 'Chaîne de custody', + 'nav.verify': 'Vérifier', + 'nav.dashboard': 'Tableau de bord', + 'verify.title': 'Vérifier le certificat', + 'verify.placeholder': 'ID de certificat ou hash de transaction', + 'verify.submit': 'Vérifier', + 'error.not_found': 'Introuvable', + 'error.invalid_signature': 'Signature invalide', + }, + de: { + 'home.title': 'SolarProof', + 'home.tagline': 'Ende-zu-Ende-kryptografischer Nachweis erneuerbarer Energie auf Stellar.', + 'home.subtitle': 'Jede kWh am Zähler signiert · on-chain verankert · öffentlich prüfbar', + 'home.verify_cta': 'Zertifikat prüfen', + 'home.dashboard_cta': 'Dashboard', + 'home.chain_title': 'Herkunftsnachweis', + 'nav.verify': 'Prüfen', + 'nav.dashboard': 'Dashboard', + 'verify.title': 'Zertifikat prüfen', + 'verify.placeholder': 'Zertifikats-ID oder Transaktions-Hash', + 'verify.submit': 'Prüfen', + 'error.not_found': 'Nicht gefunden', + 'error.invalid_signature': 'Ungültige Signatur', + }, + pt: { + 'home.title': 'SolarProof', + 'home.tagline': 'Prova criptográfica ponta a ponta de energia renovável no Stellar.', + 'home.subtitle': 'Cada kWh assinado no medidor · ancorado on-chain · verificável publicamente', + 'home.verify_cta': 'Verificar certificado', + 'home.dashboard_cta': 'Painel', + 'home.chain_title': 'Cadeia de custódia', + 'nav.verify': 'Verificar', + 'nav.dashboard': 'Painel', + 'verify.title': 'Verificar certificado', + 'verify.placeholder': 'ID de certificado ou hash de transação', + 'verify.submit': 'Verificar', + 'error.not_found': 'Não encontrado', + 'error.invalid_signature': 'Assinatura inválida', + }, +} satisfies Record> + +export type MessageKey = keyof typeof messages['en'] + +/** + * Returns a translate function for the given locale. + * Falls back to English if the key is missing in the requested locale. + */ +export function getTranslations(locale: Locale) { + return function t(key: MessageKey): string { + return messages[locale]?.[key] ?? messages['en'][key] ?? key + } +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 0000000..78c551a --- /dev/null +++ b/apps/web/src/middleware.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt'] as const +export type Locale = typeof SUPPORTED_LOCALES[number] +export const DEFAULT_LOCALE: Locale = 'en' + +/** + * Parse the Accept-Language header and return the best supported locale, + * falling back to DEFAULT_LOCALE if none match. + */ +export function detectLocale(acceptLanguage: string | null): Locale { + if (!acceptLanguage) return DEFAULT_LOCALE + for (const part of acceptLanguage.split(',')) { + const lang = part.split(';')[0].trim().toLowerCase().slice(0, 2) as Locale + if (SUPPORTED_LOCALES.includes(lang)) return lang + } + return DEFAULT_LOCALE +} + +export function middleware(req: NextRequest) { + const { pathname } = req.nextUrl + + // Skip API routes, static files, and Next.js internals + if ( + pathname.startsWith('/api/') || + pathname.startsWith('/_next/') || + pathname.startsWith('/favicon') + ) { + return NextResponse.next() + } + + // If a locale cookie is already set, honour it + const cookieLocale = req.cookies.get('locale')?.value as Locale | undefined + const locale = SUPPORTED_LOCALES.includes(cookieLocale as Locale) + ? (cookieLocale as Locale) + : detectLocale(req.headers.get('accept-language')) + + const res = NextResponse.next() + // Forward resolved locale to server components via header + res.headers.set('x-locale', locale) + // Persist in cookie so subsequent requests skip detection + if (!cookieLocale || cookieLocale !== locale) { + res.cookies.set('locale', locale, { path: '/', sameSite: 'lax', maxAge: 60 * 60 * 24 * 365 }) + } + return res +} + +export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }