- Comment ça marche
+ {await t("howItWorks.eyebrow")}
- Trois étapes pour s'échapper.
+ {await t("howItWorks.title")}
diff --git a/src/components/landing/TestimonialsSection.tsx b/src/components/landing/TestimonialsSection.tsx
index 5771d7e..3a2795c 100644
--- a/src/components/landing/TestimonialsSection.tsx
+++ b/src/components/landing/TestimonialsSection.tsx
@@ -1,9 +1,10 @@
+import { t } from "@/lib/i18n/server";
+
/**
- * Section témoignages — 3 stubs avec noms/contextes plausibles Guyane.
- * Les contenus sont éditorialisés par défaut, remplaçables via le plugin
- * `content-pages` (Phase 4) qui fournira un store éditable depuis l'admin.
+ * Section témoignages — i18n pour les headers, contenu des citations conservé
+ * tel quel (les vraies paroles restent en VO).
*/
-export function TestimonialsSection() {
+export async function TestimonialsSection() {
const items = [
{
name: "Émilie · CE Hôpital Cayenne",
@@ -30,10 +31,10 @@ export function TestimonialsSection() {
- Pas de marketing
+ {await t("testimonials.eyebrow")}
- Ils nous l'ont dit comme ça.
+ {await t("testimonials.title")}
diff --git a/src/lib/i18n/client.tsx b/src/lib/i18n/client.tsx
new file mode 100644
index 0000000..69a9be6
--- /dev/null
+++ b/src/lib/i18n/client.tsx
@@ -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
;
+};
+
+const Ctx = createContext({ locale: "fr", messages: {} });
+
+export function LocaleProvider({
+ locale,
+ messages,
+ children,
+}: {
+ locale: Locale;
+ messages: Record;
+ children: ReactNode;
+}) {
+ const value = useMemo(() => ({ locale, messages }), [locale, messages]);
+ return {children};
+}
+
+export function useLocale(): Locale {
+ return useContext(Ctx).locale;
+}
+
+export function useT() {
+ const { messages } = useContext(Ctx);
+ return useCallback(
+ (key: string) => messages[key] ?? key,
+ [messages],
+ );
+}
diff --git a/src/lib/i18n/server.ts b/src/lib/i18n/server.ts
new file mode 100644
index 0000000..30c9203
--- /dev/null
+++ b/src/lib/i18n/server.ts
@@ -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> = {
+ fr: frMessages as Record,
+ en: enMessages as Record,
+};
+
+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 {
+ // 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 {
+ 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> {
+ const lang = locale ?? (await getLocale());
+ return DICTS[lang];
+}
diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts
new file mode 100644
index 0000000..3f537c4
--- /dev/null
+++ b/src/lib/i18n/types.ts
@@ -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";
+}
diff --git a/src/messages/en.json b/src/messages/en.json
new file mode 100644
index 0000000..4a32fe0
--- /dev/null
+++ b/src/messages/en.json
@@ -0,0 +1,71 @@
+{
+ "site.tagline": "Karbé — riverside carbets of French Guiana",
+ "site.description": "The not-for-profit marketplace to rent a carbet along the rivers of French Guiana.",
+
+ "hero.eyebrow": "Solidarity marketplace — zero commission on stays",
+ "hero.titleLine1": "The sleeping karbé",
+ "hero.titleAccent": "is waiting for you",
+ "hero.subtitle": "Rent a carbet along the Maroni, Approuague or Oyapock. The hammock is up, the pirogue glides, the silence is real. For a few nights, the river is yours.",
+ "hero.ctaDiscover": "Find a carbet",
+ "hero.ctaPropose": "List mine",
+
+ "experiences.eyebrow": "Two ways to live Karbé",
+ "experiences.title": "From riverbank to pirogue expedition.",
+ "experiences.subtitle": "Pick the carbet you reach by car for an easy weekend — or the one you can only reach by pirogue, hours from the last village.",
+ "experiences.roadFluve.tag": "🛣️ Road + river",
+ "experiences.roadFluve.title": "The weekend carbet",
+ "experiences.roadFluve.body": "Accessible by track from Kourou, Saint-Laurent or Régina. Park the car, grab your gear, you're there. For families, couples who want quiet without logistics, social committees booking short stays.",
+ "experiences.roadFluve.b1": "1 to 3 nights typical",
+ "experiences.roadFluve.b2": "Car or 4WD depending on the track",
+ "experiences.roadFluve.b3": "Equipped carbets, swimming possible",
+ "experiences.riverOnly.tag": "🛶 River expedition",
+ "experiences.riverOnly.title": "The carbet you earn",
+ "experiences.riverOnly.body": "No road. Board a pirogue at a 'dégrad', sometimes two or three hours upriver. For those who really want to sleep far — howler monkeys, sky without halo, the river five meters from your hammock.",
+ "experiences.riverOnly.b1": "2 nights minimum recommended",
+ "experiences.riverOnly.b2": "Pirogue with skipper (host or partner)",
+ "experiences.riverOnly.b3": "Dry season recommended (July-November)",
+
+ "howItWorks.eyebrow": "How it works",
+ "howItWorks.title": "Three steps to disappear.",
+ "howItWorks.step1.title": "Choose the river",
+ "howItWorks.step1.body": "Maroni, Approuague, Comté, Oyapock — each river has its mood, its embarkation point, its carbets. Filter by your level of adventure.",
+ "howItWorks.step2.title": "Book the carbet",
+ "howItWorks.step2.body": "Dates, party size, pirogue duration if needed. Secure Stripe payment, paid in full to the host — zero commission on the stay.",
+ "howItWorks.step3.title": "Sleep for real",
+ "howItWorks.step3.body": "The host (or their partner) picks you up at the dégrad if needed. You get the keys to the karbé, hang the hammock, listen. Nothing left to do.",
+
+ "ce.eyebrow": "For social committees",
+ "ce.title": "Carbets sleep when you're not there.",
+ "ce.titleAccent": "Let's share them.",
+ "ce.body": "Karbé is built so that social committees who already own a carbet can reserve it for their members on selected weekends, then open it to public travellers the rest of the year. Zero commission on stays: the payment goes straight back to the committee.",
+ "ce.kpi.commission": "commission on stays",
+ "ce.kpi.ceFirst": "your members book first",
+ "ce.kpi.publicOpen": "remaining dates feed the pot",
+ "ce.kpi.noPaperwork": "Stripe charges and remits directly",
+ "ce.cta": "Learn more for your committee",
+
+ "testimonials.eyebrow": "No marketing",
+ "testimonials.title": "They told us, just like that.",
+
+ "footer.tagline": "Marketplace for riverside carbets of French Guiana. Solidarity with local social committees. Zero commission on stays.",
+ "footer.col.discover": "Discover",
+ "footer.col.propose": "Host",
+ "footer.col.legal": "Legal",
+ "footer.copyright": "non-profit digital project in French Guiana.",
+
+ "season.dry": "Dry season",
+ "season.lowWater": "Low water",
+ "season.wet": "Rainy season",
+ "season.dry.message": "Optimal conditions: rivers navigable, roads in good shape, sunrise over burning water.",
+ "season.lowWater.message": "Low water period: river-only carbets may not be reachable. Filter available on the search page.",
+ "season.wet.message": "Frequent rain: the jungle is dense and alive, plan an adapted vehicle for road+river carbets.",
+
+ "access.roadAndRiver": "🛣️ Road + river",
+ "access.riverOnly": "🛶 River expedition",
+ "access.roadAndRiver.title": "Accessible by road and by river.",
+ "access.riverOnly.title": "Reachable only by pirogue from a 'dégrad'.",
+
+ "language.switch": "Language",
+ "language.fr": "Français",
+ "language.en": "English"
+}
diff --git a/src/messages/fr.json b/src/messages/fr.json
new file mode 100644
index 0000000..ad73350
--- /dev/null
+++ b/src/messages/fr.json
@@ -0,0 +1,71 @@
+{
+ "site.tagline": "Karbé — carbets fluviaux de Guyane",
+ "site.description": "La marketplace solidaire pour louer un carbet le long des fleuves de Guyane.",
+
+ "hero.eyebrow": "Marketplace solidaire — sans commission sur le séjour",
+ "hero.titleLine1": "Le karbé qui dort",
+ "hero.titleAccent": "vous attend",
+ "hero.subtitle": "Louez un carbet le long du Maroni, de l'Approuague ou de l'Oyapock. Le hamac est tendu, la pirogue glisse, le silence est vrai. Pour quelques nuits, le fleuve vous appartient.",
+ "hero.ctaDiscover": "Découvrir un carbet",
+ "hero.ctaPropose": "Proposer le mien",
+
+ "experiences.eyebrow": "Deux façons de vivre Karbé",
+ "experiences.title": "Du bord du fleuve à l'expédition pirogue.",
+ "experiences.subtitle": "Selon l'envie, on choisit le carbet qui se rejoint en voiture pour un week-end facile, ou celui qu'on n'atteint qu'en pirogue, à plusieurs heures du dernier village.",
+ "experiences.roadFluve.tag": "🛣️ Route + fleuve",
+ "experiences.roadFluve.title": "Le carbet du week-end",
+ "experiences.roadFluve.body": "Accessible par la piste depuis Kourou, Saint-Laurent ou Régina. Garez la voiture, prenez vos affaires et vous y êtes. Pour les familles, les couples qui veulent du calme sans logistique, les CE qui réservent des séjours courts.",
+ "experiences.roadFluve.b1": "1 à 3 nuits typiques",
+ "experiences.roadFluve.b2": "Voiture ou 4×4 selon la piste",
+ "experiences.roadFluve.b3": "Carbets équipés, baignade possible",
+ "experiences.riverOnly.tag": "🛶 Expédition fleuve",
+ "experiences.riverOnly.title": "Le carbet qu'on mérite",
+ "experiences.riverOnly.body": "Aucune route n'y mène. On embarque en pirogue depuis un dégrad, parfois deux ou trois heures de remontée. Pour ceux qui veulent vraiment dormir loin — singes hurleurs, ciel sans halo, l'eau du fleuve à 5 mètres du hamac.",
+ "experiences.riverOnly.b1": "2 nuits minimum recommandées",
+ "experiences.riverOnly.b2": "Pirogue avec passeur (loueur ou partenaire)",
+ "experiences.riverOnly.b3": "Saison sèche conseillée (juillet-novembre)",
+
+ "howItWorks.eyebrow": "Comment ça marche",
+ "howItWorks.title": "Trois étapes pour s'échapper.",
+ "howItWorks.step1.title": "Choisissez le fleuve",
+ "howItWorks.step1.body": "Maroni, Approuague, Comté, Oyapock — chaque fleuve a son ambiance, son embarquement, ses carbets. Filtrez selon votre niveau d'aventure.",
+ "howItWorks.step2.title": "Réservez le carbet",
+ "howItWorks.step2.body": "Dates, capacité, durée de pirogue le cas échéant. Paiement sécurisé Stripe, reversé au loueur sans commission sur le séjour.",
+ "howItWorks.step3.title": "Dormez vrai",
+ "howItWorks.step3.body": "Le loueur (ou son partenaire) vous récupère au dégrad si besoin. Vous récupérez les clés du karbé, tendez le hamac, écoutez. Plus rien à faire.",
+
+ "ce.eyebrow": "Pour comités d'entreprise",
+ "ce.title": "Les carbets dorment quand vous n'y êtes pas.",
+ "ce.titleAccent": "Partageons-les.",
+ "ce.body": "Karbé est conçu pour que les comités sociaux possédant déjà un carbet le réservent à leurs membres certains week-ends, et l'ouvrent au public touriste le reste de l'année. Sans commission sur le séjour : le paiement revient intégralement au CE.",
+ "ce.kpi.commission": "de commission sur le séjour",
+ "ce.kpi.ceFirst": "vos membres réservent en priorité",
+ "ce.kpi.publicOpen": "le reste des dates rentre dans le pot",
+ "ce.kpi.noPaperwork": "Stripe encaisse et reverse direct",
+ "ce.cta": "En savoir plus pour votre CE",
+
+ "testimonials.eyebrow": "Pas de marketing",
+ "testimonials.title": "Ils nous l'ont dit comme ça.",
+
+ "footer.tagline": "Marketplace des carbets fluviaux de Guyane. Solidaire avec les CE locaux. Sans commission sur le séjour.",
+ "footer.col.discover": "Découvrir",
+ "footer.col.propose": "Proposer",
+ "footer.col.legal": "Légal",
+ "footer.copyright": "projet associatif numérique en Guyane.",
+
+ "season.dry": "Saison sèche",
+ "season.lowWater": "Étiage",
+ "season.wet": "Saison des pluies",
+ "season.dry.message": "Conditions optimales : fleuves navigables, pistes route en bon état, lever de soleil sur l'eau brûlante.",
+ "season.lowWater.message": "Étiage en cours : les carbets fleuve uniquement peuvent ne pas être accessibles. Filtre dispo en page recherche.",
+ "season.wet.message": "Pluies fréquentes : la jungle est dense et vivante, prévoir un véhicule adapté pour les carbets route+fleuve.",
+
+ "access.roadAndRiver": "🛣️ Route + fleuve",
+ "access.riverOnly": "🛶 Expédition fleuve",
+ "access.roadAndRiver.title": "Accessible par la route et par le fleuve.",
+ "access.riverOnly.title": "Accessible uniquement par pirogue depuis un dégrad.",
+
+ "language.switch": "Langue",
+ "language.fr": "Français",
+ "language.en": "English"
+}