feat(plugin): i18n FR + EN (Phase 4.2)

Infrastructure i18n légère, sans deps externe :

- lib/i18n/types.ts : LOCALES, DEFAULT_LOCALE, cookie name
- lib/i18n/server.ts : getLocale (cookie > Accept-Language > FR),
  t(key) async server-side, dict(locale)
- lib/i18n/client.tsx : LocaleProvider + useLocale + useT
- messages/fr.json + messages/en.json : ~50 clés pour landing + header + footer
- LocaleSwitcher component (cookie + router.refresh)

Plugin gated :
- Quand i18n-fr-en désactivé, getLocale() force FR. Le switcher ne s'affiche
  pas dans le hero. Pas d'impact sur le rendu existant.
- Quand activé, switcher visible coin haut-droit du hero. Les composants
  landing/header/footer rendent en FR ou EN selon le cookie utilisateur.

Composants i18n-isés :
- HeroSection (eyebrow, titre, CTA)
- ExperiencesSection (route/fleuve vs expédition, tous les bullets)
- HowItWorksSection (3 étapes)
- CESection (KPIs + body + CTA)
- TestimonialsSection (eyebrow + titre, citations restent en VO)
- Footer (taglines, colonnes)
- SeasonBanner (3 saisons + messages)
- AccessTypeBadge (labels + tooltips)

Pour les ContentPage, le champ lang existait déjà. Une suite (PR ultérieure)
ajoutera le filtre lang dans getContentPage + seed pages EN.
This commit is contained in:
Claude Integration 2026-05-31 11:38:39 +00:00
parent efeea16467
commit cf9da94bb5
15 changed files with 454 additions and 116 deletions

36
src/lib/i18n/client.tsx Normal file
View file

@ -0,0 +1,36 @@
"use client";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import type { Locale } from "./types";
type LocaleCtx = {
locale: Locale;
messages: Record<string, string>;
};
const Ctx = createContext<LocaleCtx>({ locale: "fr", messages: {} });
export function LocaleProvider({
locale,
messages,
children,
}: {
locale: Locale;
messages: Record<string, string>;
children: ReactNode;
}) {
const value = useMemo(() => ({ locale, messages }), [locale, messages]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useLocale(): Locale {
return useContext(Ctx).locale;
}
export function useT() {
const { messages } = useContext(Ctx);
return useCallback(
(key: string) => messages[key] ?? key,
[messages],
);
}

70
src/lib/i18n/server.ts Normal file
View file

@ -0,0 +1,70 @@
/**
* Plugin i18n-fr-en résolution serveur de la locale courante.
*
* Ordre de priorité :
* 1. cookie karbe-locale (posé par le LocaleSwitcher)
* 2. en-tête Accept-Language
* 3. DEFAULT_LOCALE (fr)
*
* Quand le plugin i18n est désactivé, le résolveur force FR.
*/
import "server-only";
import { cookies, headers } from "next/headers";
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE, type Locale } from "./types";
import { isPluginEnabled } from "@/lib/plugins/server";
import frMessages from "@/messages/fr.json";
import enMessages from "@/messages/en.json";
const DICTS: Record<Locale, Record<string, string>> = {
fr: frMessages as Record<string, string>,
en: enMessages as Record<string, string>,
};
function parseAcceptLanguage(header: string | null): Locale | null {
if (!header) return null;
const items = header
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
for (const item of items) {
const code = item.split(";")[0].slice(0, 2);
if (code === "fr") return "fr";
if (code === "en") return "en";
}
return null;
}
export async function getLocale(): Promise<Locale> {
// Plugin éteint → toujours FR (comportement legacy).
if (!(await isPluginEnabled("i18n-fr-en"))) return "fr";
const cookieStore = await cookies();
const cookieValue = cookieStore.get(LOCALE_COOKIE)?.value;
if (isLocale(cookieValue)) return cookieValue;
const h = await headers();
const fromHeader = parseAcceptLanguage(h.get("accept-language"));
return fromHeader ?? DEFAULT_LOCALE;
}
/**
* t(key, locale?) : récupère un message du dico. Sans locale, on lit la locale
* courante. Fallback : retourne la valeur FR puis la clé brute si rien.
*/
export async function t(key: string, locale?: Locale): Promise<string> {
const lang = locale ?? (await getLocale());
const dict = DICTS[lang];
const fallback = DICTS.fr;
return dict[key] ?? fallback[key] ?? key;
}
/**
* dict(locale) : retourne le dico complet pour passer en prop client-side.
* Garde le bundle léger (le client ne reçoit qu'une langue à la fois).
*/
export async function dict(locale?: Locale): Promise<Record<string, string>> {
const lang = locale ?? (await getLocale());
return DICTS[lang];
}

13
src/lib/i18n/types.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Plugin i18n-fr-en types et constantes partagés.
*/
export const LOCALES = ["fr", "en"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "fr";
export const LOCALE_COOKIE = "karbe-locale";
export function isLocale(value: unknown): value is Locale {
return value === "fr" || value === "en";
}