Merge pull request 'feat(plugin): i18n FR + EN' (#35) from feat/i18n-fr-en into main

This commit is contained in:
tarzzan 2026-05-31 11:38:41 +00:00
commit 88a937f2fd
15 changed files with 454 additions and 116 deletions

View file

@ -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 (
<html
lang="fr"
lang={locale}
className={`${geistSans.variable} ${geistMono.variable} ${cormorant.variable} h-full antialiased`}
>
<body
@ -78,8 +82,10 @@ export default async function RootLayout({
className="min-h-full flex flex-col font-sans"
>
<PluginProvider enabledKeys={enabledKeys}>
<SeasonBanner />
{children}
<LocaleProvider locale={locale} messages={messages}>
<SeasonBanner />
{children}
</LocaleProvider>
</PluginProvider>
</body>
</html>

View file

@ -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 (
<span
className={`inline-flex items-center gap-1 rounded-full font-medium ring-1 ${styles} ${sizing}`}
title={
isExpedition
? "Accessible uniquement par pirogue depuis un dégrad."
: "Accessible par la route et par le fleuve."
}
title={isExpedition ? t("access.riverOnly.title") : t("access.roadAndRiver.title")}
>
{label}
</span>

View file

@ -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 (
<div className="inline-flex items-center gap-1 rounded-full border border-[var(--color-karbe-bone)]/30 bg-[var(--color-karbe-bone)]/10 p-0.5 text-[11px] font-semibold backdrop-blur-sm">
<span className="sr-only">{t("language.switch")}</span>
<button
type="button"
onClick={() => setLocale("fr")}
disabled={pending}
aria-pressed={current === "fr"}
className={`rounded-full px-2.5 py-0.5 uppercase tracking-wider transition ${
current === "fr"
? "bg-[var(--color-karbe-bone)] text-[var(--color-karbe-canopy-900)]"
: "text-[var(--color-karbe-bone)] hover:bg-[var(--color-karbe-bone)]/15"
} disabled:opacity-50`}
>
FR
</button>
<button
type="button"
onClick={() => setLocale("en")}
disabled={pending}
aria-pressed={current === "en"}
className={`rounded-full px-2.5 py-0.5 uppercase tracking-wider transition ${
current === "en"
? "bg-[var(--color-karbe-bone)] text-[var(--color-karbe-canopy-900)]"
: "text-[var(--color-karbe-bone)] hover:bg-[var(--color-karbe-bone)]/15"
} disabled:opacity-50`}
>
EN
</button>
</div>
);
}

View file

@ -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<typeof season, string> = {
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 (
<aside className={`${tones[meta.tone]} text-xs sm:text-sm`}>
<aside className={`${TONES[meta.tone]} text-xs sm:text-sm`}>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-6 py-2 sm:px-8 lg:px-12">
<span className="flex items-center gap-2">
<span aria-hidden>{meta.emoji}</span>
<span className="font-semibold uppercase tracking-wider">{meta.label}</span>
<span className="font-semibold uppercase tracking-wider">{label}</span>
<span className="hidden text-[var(--color-karbe-bone)]/85 sm:inline">
· {messages[season]}
· {message}
</span>
</span>
</div>

View file

@ -1,36 +1,36 @@
import Link from "next/link";
import { t } from "@/lib/i18n/server";
/**
* Section dédiée aux Comités d'Entreprise (CE). Registre coop/solidaire,
* voix différente du reste de la home (qui parle au touriste aventurier).
* Section dédiée aux Comités d'Entreprise (CE). Registre coop/solidaire, i18n.
*/
export function CESection() {
export async function CESection() {
const kpis = [
{ k: "0 %", v: await t("ce.kpi.commission") },
{ k: "CE first", v: await t("ce.kpi.ceFirst") },
{ k: "Public open", v: await t("ce.kpi.publicOpen") },
{ k: "Stripe", v: await t("ce.kpi.noPaperwork") },
];
return (
<section className="bg-[var(--color-karbe-canopy-700)] text-[var(--color-karbe-bone)]">
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-20 sm:px-8 sm:py-24 lg:grid-cols-2 lg:gap-16 lg:px-12">
<div>
<span className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--color-karbe-laterite-300)]">
Pour comités d&apos;entreprise
{await t("ce.eyebrow")}
</span>
<h2 className="mt-3 font-serif text-3xl font-medium leading-tight tracking-tight sm:text-4xl md:text-5xl">
Les carbets dorment quand vous n&apos;y êtes pas.
{await t("ce.title")}
<br />
<span className="italic text-[var(--color-karbe-bone)]/80">Partageons-les.</span>
<span className="italic text-[var(--color-karbe-bone)]/80">{await t("ce.titleAccent")}</span>
</h2>
<p className="mt-5 text-base leading-relaxed text-[var(--color-karbe-bone)]/85 sm:text-lg">
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&apos;ouvrent au public touriste le reste de
l&apos;année. Sans commission sur le séjour : le paiement revient intégralement au CE.
{await t("ce.body")}
</p>
</div>
<ul className="grid grid-cols-1 gap-4 self-center sm:grid-cols-2">
{[
{ k: "0 %", v: "de commission sur le séjour" },
{ k: "CE first", v: "vos membres réservent en priorité" },
{ k: "Public ouvert", v: "le reste des dates rentre dans le pot" },
{ k: "Sans paperasse", v: "Stripe encaisse et reverse direct" },
].map(({ k, v }) => (
{kpis.map(({ k, v }) => (
<li
key={k}
className="rounded-2xl border border-[var(--color-karbe-bone)]/15 bg-[var(--color-karbe-bone)]/5 p-5 backdrop-blur-sm"
@ -44,7 +44,7 @@ export function CESection() {
href="/pour-comites-entreprise"
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-karbe-laterite-500)] px-5 py-2.5 text-sm font-semibold text-[var(--color-karbe-bone)] transition hover:bg-[var(--color-karbe-laterite-700)]"
>
En savoir plus pour votre CE
{await t("ce.cta")}
<span aria-hidden></span>
</Link>
</li>

View file

@ -1,24 +1,23 @@
import { RoadIcon, PirogueIcon } from "@/components/illustrations/Icons";
import { t } from "@/lib/i18n/server";
/**
* Section « 2 expériences » route+fleuve vs expédition fleuve.
* Reflète la distinction métier qu'on appliquera côté schema dans le plugin
* `access-type`. Ici on ne fait que l'éditorialiser pour la home.
* Texte i18n via t() server-side.
*/
export function ExperiencesSection() {
export async function ExperiencesSection() {
return (
<section className="relative bg-[var(--color-karbe-bone)] py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
<div className="max-w-2xl">
<span className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--color-karbe-canopy-700)]">
Deux façons de vivre Karbé
{await t("experiences.eyebrow")}
</span>
<h2 className="mt-3 font-serif text-3xl font-medium tracking-tight text-[var(--color-karbe-ink)] sm:text-4xl md:text-5xl">
Du bord du fleuve à l&apos;expédition pirogue.
{await t("experiences.title")}
</h2>
<p className="mt-4 max-w-xl text-base leading-relaxed text-[var(--color-karbe-ink)]/75 sm:text-lg">
Selon l&apos;envie, on choisit le carbet qui se rejoint en voiture pour un week-end facile,
ou celui qu&apos;on n&apos;atteint qu&apos;en pirogue, à plusieurs heures du dernier village.
{await t("experiences.subtitle")}
</p>
</div>
@ -29,20 +28,18 @@ export function ExperiencesSection() {
<RoadIcon className="h-full w-full" />
</div>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-canopy-500)]">
🛣 Route + fleuve
{await t("experiences.roadFluve.tag")}
</p>
<h3 className="mt-3 font-serif text-2xl font-medium text-[var(--color-karbe-ink)]">
Le carbet du week-end
{await t("experiences.roadFluve.title")}
</h3>
<p className="mt-3 text-sm leading-relaxed text-[var(--color-karbe-ink)]/75">
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.
{await t("experiences.roadFluve.body")}
</p>
<ul className="mt-5 space-y-1.5 text-sm text-[var(--color-karbe-ink)]/70">
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-canopy-500)]" /> 1 à 3 nuits typiques</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-canopy-500)]" /> Voiture ou 4×4 selon la piste</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-canopy-500)]" /> Carbets équipés, baignade possible</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-canopy-500)]" /> {await t("experiences.roadFluve.b1")}</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-canopy-500)]" /> {await t("experiences.roadFluve.b2")}</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-canopy-500)]" /> {await t("experiences.roadFluve.b3")}</li>
</ul>
</article>
@ -52,20 +49,18 @@ export function ExperiencesSection() {
<PirogueIcon className="h-full w-full" />
</div>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-700)]">
🛶 Expédition fleuve
{await t("experiences.riverOnly.tag")}
</p>
<h3 className="mt-3 font-serif text-2xl font-medium text-[var(--color-karbe-ink)]">
Le carbet qu&apos;on mérite
{await t("experiences.riverOnly.title")}
</h3>
<p className="mt-3 text-sm leading-relaxed text-[var(--color-karbe-ink)]/75">
Aucune route n&apos;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&apos;eau du fleuve à 5 mètres du hamac.
{await t("experiences.riverOnly.body")}
</p>
<ul className="mt-5 space-y-1.5 text-sm text-[var(--color-karbe-ink)]/70">
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-laterite-500)]" /> 2 nuits minimum recommandées</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-laterite-500)]" /> Pirogue avec passeur (loueur ou partenaire)</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-laterite-500)]" /> Saison sèche conseillée (juillet-novembre)</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-laterite-500)]" /> {await t("experiences.riverOnly.b1")}</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-laterite-500)]" /> {await t("experiences.riverOnly.b2")}</li>
<li className="flex items-center gap-2"><span className="h-1 w-1 rounded-full bg-[var(--color-karbe-laterite-500)]" /> {await t("experiences.riverOnly.b3")}</li>
</ul>
</article>
</div>

View file

@ -1,7 +1,8 @@
import Link from "next/link";
import { PalmIcon, WaveIcon } from "@/components/illustrations/Icons";
import { t } from "@/lib/i18n/server";
export function LandingFooter() {
export async function LandingFooter() {
const year = new Date().getFullYear();
return (
<footer className="relative bg-[var(--color-karbe-canopy-900)] text-[var(--color-karbe-bone)]/85">
@ -14,42 +15,47 @@ export function LandingFooter() {
<span className="font-serif text-2xl font-medium tracking-tight text-[var(--color-karbe-bone)]">Karbé</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-[var(--color-karbe-bone)]/65">
Marketplace des carbets fluviaux de Guyane. Solidaire avec les CE locaux. Sans
commission sur le séjour.
{await t("footer.tagline")}
</p>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-300)]">Découvrir</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-300)]">
{await t("footer.col.discover")}
</h4>
<ul className="mt-4 space-y-2 text-sm">
<li><Link href="/carbets" className="hover:text-[var(--color-karbe-bone)]">Tous les carbets</Link></li>
<li><Link href="/comment-ca-marche" className="hover:text-[var(--color-karbe-bone)]">Comment ça marche</Link></li>
<li><Link href="/a-propos" className="hover:text-[var(--color-karbe-bone)]">À propos de Karbé</Link></li>
<li><Link href="/carbets" className="hover:text-[var(--color-karbe-bone)]">{await t("hero.ctaDiscover")}</Link></li>
<li><Link href="/comment-ca-marche" className="hover:text-[var(--color-karbe-bone)]">{await t("howItWorks.eyebrow")}</Link></li>
<li><Link href="/a-propos" className="hover:text-[var(--color-karbe-bone)]">Karbé</Link></li>
</ul>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-300)]">Proposer</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-300)]">
{await t("footer.col.propose")}
</h4>
<ul className="mt-4 space-y-2 text-sm">
<li><Link href="/espace-hote" className="hover:text-[var(--color-karbe-bone)]">Devenir loueur</Link></li>
<li><Link href="/pour-comites-entreprise" className="hover:text-[var(--color-karbe-bone)]">Pour comités d&apos;entreprise</Link></li>
<li><Link href="/connexion" className="hover:text-[var(--color-karbe-bone)]">Espace membre</Link></li>
<li><Link href="/devenir-loueur" className="hover:text-[var(--color-karbe-bone)]">{await t("hero.ctaPropose")}</Link></li>
<li><Link href="/pour-comites-entreprise" className="hover:text-[var(--color-karbe-bone)]">{await t("ce.eyebrow")}</Link></li>
<li><Link href="/connexion" className="hover:text-[var(--color-karbe-bone)]">Stripe</Link></li>
</ul>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-300)]">Légal</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-karbe-laterite-300)]">
{await t("footer.col.legal")}
</h4>
<ul className="mt-4 space-y-2 text-sm">
<li><Link href="/cgv" className="hover:text-[var(--color-karbe-bone)]">CGV</Link></li>
<li><Link href="/mentions-legales" className="hover:text-[var(--color-karbe-bone)]">Mentions légales</Link></li>
<li><Link href="/politique-de-confidentialite" className="hover:text-[var(--color-karbe-bone)]">Confidentialité</Link></li>
<li><Link href="/mentions-legales" className="hover:text-[var(--color-karbe-bone)]">Mentions</Link></li>
<li><Link href="/politique-de-confidentialite" className="hover:text-[var(--color-karbe-bone)]">RGPD</Link></li>
</ul>
</div>
</div>
<div className="border-t border-[var(--color-karbe-bone)]/10">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-6 py-5 text-xs text-[var(--color-karbe-bone)]/55 sm:flex-row sm:px-8 lg:px-12">
<span>© {year} Karbé projet associatif numérique en Guyane.</span>
<span>© {year} Karbé {await t("footer.copyright")}</span>
<span className="font-mono text-[var(--color-karbe-bone)]/40">karbe.cosmolan.fr</span>
</div>
</div>

View file

@ -1,36 +1,50 @@
import Link from "next/link";
import { CarbetRiver } from "@/components/illustrations/CarbetRiver";
import { LocaleSwitcher } from "@/components/LocaleSwitcher";
import { isPluginEnabled } from "@/lib/plugins/server";
import { t } from "@/lib/i18n/server";
/**
* Hero plein écran. Plugin `landing-hero`.
* Pas de dépendance image externe illustration vectorielle inline.
* Hero plein écran. Plugin `landing-hero`. Texte i18n via t() server.
* Affiche le LocaleSwitcher en haut à droite si le plugin i18n est activé.
*/
export function HeroSection() {
export async function HeroSection() {
const i18nOn = await isPluginEnabled("i18n-fr-en");
const eyebrow = await t("hero.eyebrow");
const titleLine1 = await t("hero.titleLine1");
const titleAccent = await t("hero.titleAccent");
const subtitle = await t("hero.subtitle");
const ctaDiscover = await t("hero.ctaDiscover");
const ctaPropose = await t("hero.ctaPropose");
return (
<section className="relative isolate overflow-hidden bg-[var(--color-karbe-canopy-900)] text-[var(--color-karbe-bone)]">
{/* fond illustration */}
<div className="absolute inset-0 -z-10">
<CarbetRiver className="h-full w-full object-cover opacity-90 [object-position:center_60%]" />
{/* voile sombre pour lisibilité texte */}
<div className="absolute inset-0 bg-gradient-to-t from-[var(--color-karbe-canopy-900)] via-[var(--color-karbe-canopy-900)]/45 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-[var(--color-karbe-canopy-900)]/70 via-transparent to-transparent" />
</div>
{i18nOn ? (
<div className="absolute right-6 top-6 z-10 sm:right-8 lg:right-12">
<LocaleSwitcher />
</div>
) : null}
<div className="mx-auto flex min-h-[78vh] max-w-6xl flex-col items-start justify-end gap-6 px-6 pb-16 pt-32 sm:px-8 sm:pb-20 sm:pt-40 lg:px-12">
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-karbe-bone)]/30 bg-[var(--color-karbe-bone)]/10 px-3 py-1 text-xs font-medium uppercase tracking-wider backdrop-blur-sm">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-karbe-laterite-300)]" />
Marketplace solidaire sans commission sur le séjour
{eyebrow}
</span>
<h1 className="max-w-3xl font-serif text-4xl font-medium leading-[1.05] tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
Le karbé qui dort
{titleLine1}
<br />
<span className="text-[var(--color-karbe-laterite-300)] italic">vous attend</span>.
<span className="text-[var(--color-karbe-laterite-300)] italic">{titleAccent}</span>.
</h1>
<p className="max-w-xl text-base leading-relaxed text-[var(--color-karbe-bone)]/85 sm:text-lg">
Louez un carbet le long du Maroni, de l&apos;Approuague ou de l&apos;Oyapock. Le hamac est tendu,
la pirogue glisse, le silence est vrai. Pour quelques nuits, le fleuve vous appartient.
{subtitle}
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
@ -38,13 +52,13 @@ export function HeroSection() {
href="/carbets"
className="rounded-full bg-[var(--color-karbe-laterite-500)] px-6 py-3 text-sm font-semibold text-[var(--color-karbe-bone)] shadow-lg shadow-black/30 transition hover:bg-[var(--color-karbe-laterite-700)]"
>
Découvrir un carbet
{ctaDiscover}
</Link>
<Link
href="/espace-hote"
className="rounded-full border border-[var(--color-karbe-bone)]/40 bg-[var(--color-karbe-bone)]/5 px-6 py-3 text-sm font-medium text-[var(--color-karbe-bone)] backdrop-blur-sm transition hover:bg-[var(--color-karbe-bone)]/15"
>
Proposer le mien
{ctaPropose}
</Link>
</div>
</div>

View file

@ -1,42 +1,40 @@
import { CompassIcon, HammockIcon, HeartHandIcon } from "@/components/illustrations/Icons";
import { t } from "@/lib/i18n/server";
/**
* Section « Comment ça marche » 3 étapes côté voyageur.
* Section « Comment ça marche » 3 étapes côté voyageur, i18n via t() server.
*/
export function HowItWorksSection() {
const steps = [
export async function HowItWorksSection() {
const steps = await Promise.all([
{
icon: CompassIcon,
step: "01",
title: "Choisissez le fleuve",
body:
"Maroni, Approuague, Comté, Oyapock — chaque fleuve a son ambiance, son embarquement, ses carbets. Filtrez selon votre niveau d'aventure.",
title: await t("howItWorks.step1.title"),
body: await t("howItWorks.step1.body"),
},
{
icon: HammockIcon,
step: "02",
title: "Réservez le carbet",
body:
"Dates, capacité, durée de pirogue le cas échéant. Paiement sécurisé Stripe, reversé au loueur sans commission sur le séjour.",
title: await t("howItWorks.step2.title"),
body: await t("howItWorks.step2.body"),
},
{
icon: HeartHandIcon,
step: "03",
title: "Dormez vrai",
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.",
title: await t("howItWorks.step3.title"),
body: await t("howItWorks.step3.body"),
},
];
]);
return (
<section className="bg-gradient-to-b from-[var(--color-karbe-bone)] to-[var(--color-karbe-canopy-50)] py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
<div className="max-w-2xl">
<span className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--color-karbe-canopy-700)]">
Comment ça marche
{await t("howItWorks.eyebrow")}
</span>
<h2 className="mt-3 font-serif text-3xl font-medium tracking-tight text-[var(--color-karbe-ink)] sm:text-4xl">
Trois étapes pour s&apos;échapper.
{await t("howItWorks.title")}
</h2>
</div>

View file

@ -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() {
<div className="mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
<div className="max-w-2xl">
<span className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--color-karbe-canopy-700)]">
Pas de marketing
{await t("testimonials.eyebrow")}
</span>
<h2 className="mt-3 font-serif text-3xl font-medium tracking-tight text-[var(--color-karbe-ink)] sm:text-4xl">
Ils nous l&apos;ont dit comme ça.
{await t("testimonials.title")}
</h2>
</div>

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";
}

71
src/messages/en.json Normal file
View file

@ -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"
}

71
src/messages/fr.json Normal file
View file

@ -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"
}