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:
parent
efeea16467
commit
cf9da94bb5
15 changed files with 454 additions and 116 deletions
36
src/lib/i18n/client.tsx
Normal file
36
src/lib/i18n/client.tsx
Normal 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
70
src/lib/i18n/server.ts
Normal 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
13
src/lib/i18n/types.ts
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue