From cf9da94bb5de14a03e2d4e3069a09fca78c5616a Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 11:38:39 +0000 Subject: [PATCH] feat(plugin): i18n FR + EN (Phase 4.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/layout.tsx | 12 +++- src/components/AccessTypeBadge.tsx | 13 ++-- src/components/LocaleSwitcher.tsx | 58 +++++++++++++++ src/components/SeasonBanner.tsx | 38 +++++----- src/components/landing/CESection.tsx | 32 ++++----- src/components/landing/ExperiencesSection.tsx | 41 +++++------ src/components/landing/Footer.tsx | 36 ++++++---- src/components/landing/HeroSection.tsx | 38 ++++++---- src/components/landing/HowItWorksSection.tsx | 28 ++++---- .../landing/TestimonialsSection.tsx | 13 ++-- src/lib/i18n/client.tsx | 36 ++++++++++ src/lib/i18n/server.ts | 70 ++++++++++++++++++ src/lib/i18n/types.ts | 13 ++++ src/messages/en.json | 71 +++++++++++++++++++ src/messages/fr.json | 71 +++++++++++++++++++ 15 files changed, 454 insertions(+), 116 deletions(-) create mode 100644 src/components/LocaleSwitcher.tsx create mode 100644 src/lib/i18n/client.tsx create mode 100644 src/lib/i18n/server.ts create mode 100644 src/lib/i18n/types.ts create mode 100644 src/messages/en.json create mode 100644 src/messages/fr.json diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8b05af1..54c6919 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import "./globals.css"; import { PluginProvider } from "@/lib/plugins/client"; import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server"; import { SeasonBanner } from "@/components/SeasonBanner"; +import { LocaleProvider } from "@/lib/i18n/client"; +import { dict, getLocale } from "@/lib/i18n/server"; // Le layout interroge la DB Plugin à chaque request → rendu dynamique forcé. // Sans ça, le layout (et donc data-theme + enabledKeys passés au client) est @@ -67,10 +69,12 @@ export default async function RootLayout({ } const themeGuyane = enabledKeys.includes("theme-guyane"); + const locale = await getLocale(); + const messages = await dict(locale); return ( - - {children} + + + {children} + diff --git a/src/components/AccessTypeBadge.tsx b/src/components/AccessTypeBadge.tsx index 27b8d84..758486a 100644 --- a/src/components/AccessTypeBadge.tsx +++ b/src/components/AccessTypeBadge.tsx @@ -1,12 +1,12 @@ "use client"; import { useIsPluginEnabled } from "@/lib/plugins/client"; +import { useT } from "@/lib/i18n/client"; import type { AccessType } from "@/generated/prisma/enums"; /** * Badge route+fleuve vs fleuve only. Gated par le plugin `access-type`. - * Si le plugin est désactivé, rien n'est rendu — la fiche tombe sur le - * comportement legacy (pirogue toujours mentionnée). + * Si le plugin est désactivé, rien n'est rendu. Label i18n via useT(). */ export function AccessTypeBadge({ accessType, @@ -16,10 +16,11 @@ export function AccessTypeBadge({ size?: "sm" | "md"; }) { const enabled = useIsPluginEnabled("access-type"); + const t = useT(); if (!enabled) return null; const isExpedition = accessType === "RIVER_ONLY"; - const label = isExpedition ? "🛶 Expédition fleuve" : "🛣️ Route + fleuve"; + const label = isExpedition ? t("access.riverOnly") : t("access.roadAndRiver"); const styles = isExpedition ? "bg-[var(--color-karbe-laterite-300)]/25 text-[var(--color-karbe-laterite-700)] ring-[var(--color-karbe-laterite-500)]/30" : "bg-[var(--color-karbe-canopy-50)] text-[var(--color-karbe-canopy-700)] ring-[var(--color-karbe-canopy-500)]/30"; @@ -31,11 +32,7 @@ export function AccessTypeBadge({ return ( {label} diff --git a/src/components/LocaleSwitcher.tsx b/src/components/LocaleSwitcher.tsx new file mode 100644 index 0000000..7472539 --- /dev/null +++ b/src/components/LocaleSwitcher.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useLocale, useT } from "@/lib/i18n/client"; +import { LOCALE_COOKIE, type Locale } from "@/lib/i18n/types"; + +/** + * Switcher de langue (FR / EN). Pose le cookie karbe-locale et refresh la page + * pour que le server re-render avec la nouvelle locale. + */ +export function LocaleSwitcher() { + const router = useRouter(); + const current = useLocale(); + const t = useT(); + const [pending, startTransition] = useTransition(); + + function setLocale(next: Locale) { + if (next === current) return; + // 1 an, scope au site entier + document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; + startTransition(() => { + router.refresh(); + }); + } + + return ( +
+ {t("language.switch")} + + +
+ ); +} diff --git a/src/components/SeasonBanner.tsx b/src/components/SeasonBanner.tsx index d489cd3..2d1a6ab 100644 --- a/src/components/SeasonBanner.tsx +++ b/src/components/SeasonBanner.tsx @@ -1,38 +1,40 @@ import { isPluginEnabled } from "@/lib/plugins/server"; import { currentSeason, SEASON_META } from "@/lib/seasonality"; +import { t } from "@/lib/i18n/server"; + +const TONES = { + ok: "bg-[var(--color-karbe-canopy-700)] text-[var(--color-karbe-bone)]", + warn: "bg-[var(--color-karbe-laterite-500)] text-[var(--color-karbe-bone)]", + info: "bg-[var(--color-karbe-maroni-700)] text-[var(--color-karbe-bone)]", +} as const; + +const SEASON_KEYS = { + DRY: { label: "season.dry", message: "season.dry.message" }, + LOW_WATER: { label: "season.lowWater", message: "season.lowWater.message" }, + WET: { label: "season.wet", message: "season.wet.message" }, +} as const; /** * Bandeau saison — affiché en haut de la home et de /carbets si le plugin * `seasonality` est activé. Server component pur, pas de fetch DB. + * Texte i18n via t() server-side. */ export async function SeasonBanner() { if (!(await isPluginEnabled("seasonality"))) return null; const season = currentSeason(); const meta = SEASON_META[season]; - - const messages: Record = { - DRY: - "Conditions optimales : fleuves navigables, pistes route en bon état, lever de soleil sur l'eau brûlante.", - LOW_WATER: - "Étiage en cours : les carbets fleuve uniquement peuvent ne pas être accessibles. Filtre dispo en page recherche.", - WET: - "Pluies fréquentes : la jungle est dense et vivante, prévoir un véhicule adapté pour les carbets route+fleuve.", - }; - - const tones = { - ok: "bg-[var(--color-karbe-canopy-700)] text-[var(--color-karbe-bone)]", - warn: "bg-[var(--color-karbe-laterite-500)] text-[var(--color-karbe-bone)]", - info: "bg-[var(--color-karbe-maroni-700)] text-[var(--color-karbe-bone)]", - } as const; + const keys = SEASON_KEYS[season]; + const label = await t(keys.label); + const message = await t(keys.message); return ( -