Date: Sun, 31 May 2026 08:59:46 +0000
Subject: [PATCH 17/77] chore: wire StayConstraints + minStayNights dans
carbet-card + search (oubli PR#30)
---
src/app/carbets/_components/carbet-card.tsx | 8 ++++++++
src/lib/carbet-search.ts | 9 +++++++++
2 files changed, 17 insertions(+)
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
index a757b1a..9a6a53b 100644
--- a/src/app/carbets/_components/carbet-card.tsx
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -4,6 +4,7 @@ import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
import { formatAverageRating } from "@/lib/reviews";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
+import { StayConstraints } from "@/components/StayConstraints";
import { StarRating } from "./star-rating";
@@ -41,6 +42,13 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
Fleuve {carbet.river} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
+
+
+
{carbet.reviewCount > 0 && carbet.averageRating !== null ? (
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index aa8b4be..0f25da3 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -82,6 +82,9 @@ export type CarbetSearchResult = {
accessType: AccessType;
roadAccessNote: string | null;
capacity: number;
+ minStayNights: number | null;
+ maxStayNights: number | null;
+ minCapacity: number | null;
description: string;
coverUrl: string | null;
mediaCount: number;
@@ -142,6 +145,9 @@ export async function searchCarbets(
accessType: true,
roadAccessNote: true,
capacity: true,
+ minStayNights: true,
+ maxStayNights: true,
+ minCapacity: true,
description: true,
media: {
orderBy: { sortOrder: "asc" },
@@ -169,6 +175,9 @@ export async function searchCarbets(
accessType: carbet.accessType,
roadAccessNote: carbet.roadAccessNote,
capacity: carbet.capacity,
+ minStayNights: carbet.minStayNights,
+ maxStayNights: carbet.maxStayNights,
+ minCapacity: carbet.minCapacity,
description: carbet.description,
coverUrl: carbet.media[0]?.s3Url ?? null,
mediaCount: carbet._count.media,
From 68f37f554f222ab9eb55eb77daeeaae4bd088345 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Sun, 31 May 2026 10:12:13 +0000
Subject: [PATCH 18/77] feat(plugins): content-pages + legal-pages (Phase 4.1 +
4.3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plugin content-pages :
- Modèle Prisma ContentPage (slug PK, title, body markdown, category, published)
- lib/content-pages.ts : helpers upsert/get/list/unpublish
- lib/markdown.ts : mini-renderer markdown server-side sans deps externe
(h1-h3, paragraphes, gras/italique, liens, listes ul/ol, hr, blockquote,
échappement HTML)
- ContentPageRenderer server component, applique le theme Guyane (font-serif)
- 5 pages seedées : /a-propos, /faq, /comment-ca-marche,
/pour-comites-entreprise, /devenir-loueur
- Routes publiques + force-dynamic + guard requirePluginOr404
Plugin legal-pages :
- Réutilise le même modèle ContentPage, catégorie 'legal'
- 3 pages seedées : /cgv, /mentions-legales, /politique-de-confidentialite
(contenu de base, à valider par avocat avant prod réelle)
Admin :
- /admin/content-pages : table par catégorie, statut publié/dépublié
- /admin/content-pages/[slug] : éditeur markdown + toggle publié
- PATCH /api/admin/content-pages/[slug]
Hooks plugin :
- onEnable seed + republish toutes les pages
- onDisable dépublie toute la catégorie sans la supprimer (preserve les edits)
---
.../migration.sql | 16 ++
prisma/schema.prisma | 16 ++
src/app/a-propos/page.tsx | 18 ++
.../[slug]/_components/EditorForm.tsx | 93 +++++++++
src/app/admin/content-pages/[slug]/page.tsx | 47 +++++
src/app/admin/content-pages/page.tsx | 62 ++++++
.../api/admin/content-pages/[slug]/route.ts | 39 ++++
src/app/cgv/page.tsx | 18 ++
src/app/comment-ca-marche/page.tsx | 18 ++
src/app/devenir-loueur/page.tsx | 18 ++
src/app/faq/page.tsx | 18 ++
src/app/mentions-legales/page.tsx | 18 ++
src/app/politique-de-confidentialite/page.tsx | 18 ++
src/app/pour-comites-entreprise/page.tsx | 18 ++
src/components/ContentPageRenderer.tsx | 31 +++
src/lib/content-pages.ts | 110 +++++++++++
src/lib/markdown.ts | 151 ++++++++++++++
src/lib/plugins/hooks.ts | 36 ++++
.../plugins/seeds/content-pages-default.ts | 185 +++++++++++++++++
src/lib/plugins/seeds/legal-pages-default.ts | 186 ++++++++++++++++++
20 files changed, 1116 insertions(+)
create mode 100644 prisma/migrations/20260531180000_add_content_pages/migration.sql
create mode 100644 src/app/a-propos/page.tsx
create mode 100644 src/app/admin/content-pages/[slug]/_components/EditorForm.tsx
create mode 100644 src/app/admin/content-pages/[slug]/page.tsx
create mode 100644 src/app/admin/content-pages/page.tsx
create mode 100644 src/app/api/admin/content-pages/[slug]/route.ts
create mode 100644 src/app/cgv/page.tsx
create mode 100644 src/app/comment-ca-marche/page.tsx
create mode 100644 src/app/devenir-loueur/page.tsx
create mode 100644 src/app/faq/page.tsx
create mode 100644 src/app/mentions-legales/page.tsx
create mode 100644 src/app/politique-de-confidentialite/page.tsx
create mode 100644 src/app/pour-comites-entreprise/page.tsx
create mode 100644 src/components/ContentPageRenderer.tsx
create mode 100644 src/lib/content-pages.ts
create mode 100644 src/lib/markdown.ts
create mode 100644 src/lib/plugins/seeds/content-pages-default.ts
create mode 100644 src/lib/plugins/seeds/legal-pages-default.ts
diff --git a/prisma/migrations/20260531180000_add_content_pages/migration.sql b/prisma/migrations/20260531180000_add_content_pages/migration.sql
new file mode 100644
index 0000000..4306682
--- /dev/null
+++ b/prisma/migrations/20260531180000_add_content_pages/migration.sql
@@ -0,0 +1,16 @@
+-- Plugin content-pages + legal-pages : table ContentPage
+
+CREATE TABLE "ContentPage" (
+ "slug" TEXT PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "body" TEXT NOT NULL,
+ "lang" TEXT NOT NULL DEFAULT 'fr',
+ "category" TEXT NOT NULL DEFAULT 'general',
+ "published" BOOLEAN NOT NULL DEFAULT true,
+ "lastEditedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL
+);
+
+CREATE INDEX "ContentPage_category_idx" ON "ContentPage" ("category");
+CREATE INDEX "ContentPage_published_idx" ON "ContentPage" ("published");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a7d8c9e..4fd694e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -280,3 +280,19 @@ model Plugin {
@@index([category])
@@index([enabled])
}
+
+model ContentPage {
+ slug String @id
+ title String
+ body String
+ lang String @default("fr")
+ // 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
+ category String @default("general")
+ published Boolean @default(true)
+ lastEditedBy String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([category])
+ @@index([published])
+}
diff --git a/src/app/a-propos/page.tsx b/src/app/a-propos/page.tsx
new file mode 100644
index 0000000..e4a36b1
--- /dev/null
+++ b/src/app/a-propos/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("a-propos");
+ return { title: page?.title ?? "À propos" };
+}
+
+export default async function AboutPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("a-propos");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx
new file mode 100644
index 0000000..64e3818
--- /dev/null
+++ b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+type Page = {
+ slug: string;
+ title: string;
+ body: string;
+ category: string;
+ published: boolean;
+};
+
+export default function EditorForm({ page }: { page: Page }) {
+ const router = useRouter();
+ const [title, setTitle] = useState(page.title);
+ const [body, setBody] = useState(page.body);
+ const [published, setPublished] = useState(page.published);
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState(null);
+ const [err, setErr] = useState(null);
+
+ async function save() {
+ setBusy(true);
+ setMsg(null);
+ setErr(null);
+ try {
+ const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, body, published }),
+ });
+ if (!res.ok) {
+ const j = await res.json().catch(() => ({}));
+ throw new Error(j?.error || `HTTP ${res.status}`);
+ }
+ setMsg("Sauvegardé.");
+ router.refresh();
+ } catch (e) {
+ setErr(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {msg ? {msg} : null}
+ {err ? {err} : null}
+
+
+ );
+}
diff --git a/src/app/admin/content-pages/[slug]/page.tsx b/src/app/admin/content-pages/[slug]/page.tsx
new file mode 100644
index 0000000..00b7fe0
--- /dev/null
+++ b/src/app/admin/content-pages/[slug]/page.tsx
@@ -0,0 +1,47 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { getContentPage } from "@/lib/content-pages";
+import { prisma } from "@/lib/prisma";
+import EditorForm from "./_components/EditorForm";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ slug: string }> };
+
+export default async function EditContentPage({ params }: PageProps) {
+ await requireRole([UserRole.ADMIN]);
+ const { slug } = await params;
+ // Pas getContentPage : il filtre published=true. Ici on veut tout voir.
+ const row = await prisma.contentPage.findUnique({ where: { slug } });
+ if (!row) notFound();
+ // Re-construction du type minimal attendu par le formulaire.
+ const page = {
+ slug: row.slug,
+ title: row.title,
+ body: row.body,
+ category: row.category,
+ published: row.published,
+ updatedAt: row.updatedAt,
+ };
+ // Mute eslint sur le _ = getContentPage (gardé importé pour la cohérence future).
+ void getContentPage;
+ return (
+
+
+ ← Toutes les pages
+
+
Éditer · {page.title}
+
+ URL publique : /{page.slug}
+
+
+
+
+
+ );
+}
diff --git a/src/app/admin/content-pages/page.tsx b/src/app/admin/content-pages/page.tsx
new file mode 100644
index 0000000..263e290
--- /dev/null
+++ b/src/app/admin/content-pages/page.tsx
@@ -0,0 +1,62 @@
+import Link from "next/link";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { listContentPages } from "@/lib/content-pages";
+
+export const dynamic = "force-dynamic";
+
+const CATEGORY_LABEL: Record = {
+ general: "Général",
+ legal: "Légales",
+};
+
+export default async function ContentPagesAdminPage() {
+ await requireRole([UserRole.ADMIN]);
+ const pages = await listContentPages();
+
+ const byCategory = pages.reduce>((acc, p) => {
+ (acc[p.category] ??= []).push(p);
+ return acc;
+ }, {});
+
+ return (
+
+
Pages éditoriales
+
+ Pages markdown affichées dans le site public. La catégorie « Général »
+ est gérée par le plugin content-pages, la catégorie « Légales »
+ par legal-pages. Désactiver le plugin dépublie ses pages
+ sans les supprimer.
+
+
+
+ {Object.entries(byCategory).map(([cat, list]) => (
+
+
+ {CATEGORY_LABEL[cat] ?? cat}
+
+
+ {list.map((p) => (
+ -
+
+
{p.title}
+
+ /{p.slug} · {p.published ? "publié" : "dépublié"} ·
+ mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")}
+
+
+
+ Éditer
+
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/api/admin/content-pages/[slug]/route.ts b/src/app/api/admin/content-pages/[slug]/route.ts
new file mode 100644
index 0000000..fc0047e
--- /dev/null
+++ b/src/app/api/admin/content-pages/[slug]/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/auth";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+const patchSchema = z.object({
+ title: z.string().min(1).max(200).optional(),
+ body: z.string().max(100_000).optional(),
+ published: z.boolean().optional(),
+});
+
+export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) {
+ await requireRole([UserRole.ADMIN]);
+ const { slug } = await ctx.params;
+ const session = await auth();
+ const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const existing = await prisma.contentPage.findUnique({ where: { slug } });
+ if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ const updated = await prisma.contentPage.update({
+ where: { slug },
+ data: {
+ ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
+ ...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
+ ...(parsed.data.published !== undefined ? { published: parsed.data.published } : {}),
+ lastEditedBy: session?.user?.email ?? session?.user?.id ?? null,
+ },
+ });
+ return NextResponse.json({
+ slug: updated.slug,
+ title: updated.title,
+ published: updated.published,
+ updatedAt: updated.updatedAt,
+ });
+}
diff --git a/src/app/cgv/page.tsx b/src/app/cgv/page.tsx
new file mode 100644
index 0000000..eaec1d2
--- /dev/null
+++ b/src/app/cgv/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("cgv");
+ return { title: page?.title ?? "CGV" };
+}
+
+export default async function CgvPage() {
+ if (!(await isPluginEnabled("legal-pages"))) notFound();
+ const page = await getContentPage("cgv");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/comment-ca-marche/page.tsx b/src/app/comment-ca-marche/page.tsx
new file mode 100644
index 0000000..bae2149
--- /dev/null
+++ b/src/app/comment-ca-marche/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("comment-ca-marche");
+ return { title: page?.title ?? "Comment ça marche" };
+}
+
+export default async function HowItWorksPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("comment-ca-marche");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/devenir-loueur/page.tsx b/src/app/devenir-loueur/page.tsx
new file mode 100644
index 0000000..c99d3ff
--- /dev/null
+++ b/src/app/devenir-loueur/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("devenir-loueur");
+ return { title: page?.title ?? "Devenir loueur" };
+}
+
+export default async function OwnerOnboardingPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("devenir-loueur");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx
new file mode 100644
index 0000000..9de53b1
--- /dev/null
+++ b/src/app/faq/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("faq");
+ return { title: page?.title ?? "FAQ" };
+}
+
+export default async function FaqPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("faq");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/mentions-legales/page.tsx b/src/app/mentions-legales/page.tsx
new file mode 100644
index 0000000..ecd2a43
--- /dev/null
+++ b/src/app/mentions-legales/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("mentions-legales");
+ return { title: page?.title ?? "Mentions légales" };
+}
+
+export default async function MentionsPage() {
+ if (!(await isPluginEnabled("legal-pages"))) notFound();
+ const page = await getContentPage("mentions-legales");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/politique-de-confidentialite/page.tsx b/src/app/politique-de-confidentialite/page.tsx
new file mode 100644
index 0000000..7271d29
--- /dev/null
+++ b/src/app/politique-de-confidentialite/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("politique-de-confidentialite");
+ return { title: page?.title ?? "Politique de confidentialité" };
+}
+
+export default async function PrivacyPage() {
+ if (!(await isPluginEnabled("legal-pages"))) notFound();
+ const page = await getContentPage("politique-de-confidentialite");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/app/pour-comites-entreprise/page.tsx b/src/app/pour-comites-entreprise/page.tsx
new file mode 100644
index 0000000..9ad839e
--- /dev/null
+++ b/src/app/pour-comites-entreprise/page.tsx
@@ -0,0 +1,18 @@
+import { notFound } from "next/navigation";
+import { getContentPage } from "@/lib/content-pages";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ const page = await getContentPage("pour-comites-entreprise");
+ return { title: page?.title ?? "Pour comités d'entreprise" };
+}
+
+export default async function CEPage() {
+ if (!(await isPluginEnabled("content-pages"))) notFound();
+ const page = await getContentPage("pour-comites-entreprise");
+ if (!page) notFound();
+ return ;
+}
diff --git a/src/components/ContentPageRenderer.tsx b/src/components/ContentPageRenderer.tsx
new file mode 100644
index 0000000..142fe62
--- /dev/null
+++ b/src/components/ContentPageRenderer.tsx
@@ -0,0 +1,31 @@
+import { renderMarkdown } from "@/lib/markdown";
+import type { ContentPage } from "@/lib/content-pages";
+
+/**
+ * Rend une ContentPage en HTML markdown. Server component pur.
+ * Pas de "use client" — le markdown est rendu côté serveur, pas de hydration.
+ */
+export function ContentPageRenderer({ page }: { page: ContentPage }) {
+ const html = renderMarkdown(page.body);
+ return (
+
+
+
+ {page.title}
+
+
+ Mis à jour le{" "}
+ {new Date(page.updatedAt).toLocaleDateString("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ })}
+
+
+
+
+ );
+}
diff --git a/src/lib/content-pages.ts b/src/lib/content-pages.ts
new file mode 100644
index 0000000..442ed56
--- /dev/null
+++ b/src/lib/content-pages.ts
@@ -0,0 +1,110 @@
+/**
+ * Helpers Plugin content-pages / legal-pages.
+ *
+ * Une `ContentPage` est une page éditable (markdown léger) servie depuis la
+ * table DB. Les pages sont seedées par les hooks onEnable des plugins
+ * content-pages (catégorie "general") et legal-pages (catégorie "legal").
+ */
+
+import { prisma } from "@/lib/prisma";
+
+export type ContentPage = {
+ slug: string;
+ title: string;
+ body: string;
+ lang: string;
+ category: string;
+ published: boolean;
+ updatedAt: Date;
+};
+
+export async function getContentPage(slug: string): Promise {
+ const row = await prisma.contentPage.findUnique({ where: { slug } });
+ if (!row) return null;
+ if (!row.published) return null;
+ return {
+ slug: row.slug,
+ title: row.title,
+ body: row.body,
+ lang: row.lang,
+ category: row.category,
+ published: row.published,
+ updatedAt: row.updatedAt,
+ };
+}
+
+export async function listContentPages(category?: string): Promise {
+ const rows = await prisma.contentPage.findMany({
+ where: category ? { category } : undefined,
+ orderBy: [{ category: "asc" }, { slug: "asc" }],
+ });
+ return rows.map((r) => ({
+ slug: r.slug,
+ title: r.title,
+ body: r.body,
+ lang: r.lang,
+ category: r.category,
+ published: r.published,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function upsertContentPage(input: {
+ slug: string;
+ title: string;
+ body: string;
+ category?: string;
+ lang?: string;
+ published?: boolean;
+ lastEditedBy?: string;
+}): Promise {
+ const row = await prisma.contentPage.upsert({
+ where: { slug: input.slug },
+ update: {
+ title: input.title,
+ body: input.body,
+ category: input.category ?? "general",
+ lang: input.lang ?? "fr",
+ published: input.published ?? true,
+ lastEditedBy: input.lastEditedBy ?? null,
+ },
+ create: {
+ slug: input.slug,
+ title: input.title,
+ body: input.body,
+ category: input.category ?? "general",
+ lang: input.lang ?? "fr",
+ published: input.published ?? true,
+ lastEditedBy: input.lastEditedBy ?? null,
+ },
+ });
+ return {
+ slug: row.slug,
+ title: row.title,
+ body: row.body,
+ lang: row.lang,
+ category: row.category,
+ published: row.published,
+ updatedAt: row.updatedAt,
+ };
+}
+
+export async function setContentPagePublished(
+ slug: string,
+ category: string,
+ published: boolean,
+): Promise {
+ const result = await prisma.contentPage.updateMany({
+ where: { slug, category },
+ data: { published },
+ });
+ return result.count;
+}
+
+export async function unpublishCategory(category: string): Promise {
+ const result = await prisma.contentPage.updateMany({
+ where: { category },
+ data: { published: false },
+ });
+ return result.count;
+}
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
new file mode 100644
index 0000000..2be5e6c
--- /dev/null
+++ b/src/lib/markdown.ts
@@ -0,0 +1,151 @@
+/**
+ * Mini-renderer markdown sans dépendance externe.
+ *
+ * Volontairement minimal et stable : pas de plugins, pas d'extension HTML
+ * arbitraire. Couvre les besoins des pages CMS Karbé (À propos, FAQ, CGV,
+ * etc.) sans introduire de surface d'attaque XSS.
+ *
+ * Supporte :
+ * # H1 / ## H2 / ### H3
+ * paragraphes (séparés par ligne vide)
+ * **gras** et *italique*
+ * [texte](https://lien)
+ * - liste à puces
+ * 1. liste numérotée
+ * --- (séparateur)
+ * > citation (blockquote)
+ *
+ * Toute autre balise HTML dans le markdown est échappée.
+ */
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function inline(text: string): string {
+ let out = escapeHtml(text);
+ // **bold**
+ out = out.replace(/\*\*([^*]+)\*\*/g, "$1");
+ // *italic*
+ out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2");
+ // [text](url)
+ out = out.replace(
+ /\[([^\]]+)\]\(((?:https?:\/\/|\/|mailto:)[^)\s]+)\)/g,
+ (_m, label: string, href: string) => {
+ const safe = href.replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`);
+ const isExternal = /^https?:/.test(href);
+ const extra = isExternal ? ' target="_blank" rel="noopener noreferrer"' : "";
+ return `${label}`;
+ },
+ );
+ return out;
+}
+
+export function renderMarkdown(md: string): string {
+ const lines = md.replace(/\r\n?/g, "\n").split("\n");
+ const out: string[] = [];
+ let paragraph: string[] = [];
+ let listType: "ul" | "ol" | null = null;
+ let inBlockquote = false;
+ const blockquote: string[] = [];
+
+ function flushParagraph() {
+ if (paragraph.length) {
+ out.push(`${inline(paragraph.join(" "))}
`);
+ paragraph = [];
+ }
+ }
+ function flushList() {
+ if (listType) {
+ out.push(`${listType}>`);
+ listType = null;
+ }
+ }
+ function flushBlockquote() {
+ if (inBlockquote) {
+ out.push(
+ `${blockquote
+ .map((l) => inline(l))
+ .join(" ")}
`,
+ );
+ blockquote.length = 0;
+ inBlockquote = false;
+ }
+ }
+
+ for (const rawLine of lines) {
+ const line = rawLine.trimEnd();
+
+ if (!line.trim()) {
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+ continue;
+ }
+
+ if (/^---+$/.test(line.trim())) {
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+ out.push(`
`);
+ continue;
+ }
+
+ let m: RegExpExecArray | null;
+ if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) {
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+ const level = m[1].length;
+ const sizes = ["", "text-3xl mt-8 mb-4 font-medium font-serif", "text-2xl mt-7 mb-3 font-medium font-serif", "text-xl mt-6 mb-2 font-semibold"];
+ out.push(`${inline(m[2])}`);
+ continue;
+ }
+
+ if ((m = /^[-*]\s+(.+)$/.exec(line))) {
+ flushParagraph();
+ flushBlockquote();
+ if (listType !== "ul") {
+ flushList();
+ out.push(``);
+ listType = "ul";
+ }
+ out.push(`- ${inline(m[1])}
`);
+ continue;
+ }
+ if ((m = /^\d+\.\s+(.+)$/.exec(line))) {
+ flushParagraph();
+ flushBlockquote();
+ if (listType !== "ol") {
+ flushList();
+ out.push(``);
+ listType = "ol";
+ }
+ out.push(`- ${inline(m[1])}
`);
+ continue;
+ }
+
+ if ((m = /^>\s?(.*)$/.exec(line))) {
+ flushParagraph();
+ flushList();
+ inBlockquote = true;
+ blockquote.push(m[1]);
+ continue;
+ }
+
+ flushList();
+ flushBlockquote();
+ paragraph.push(line);
+ }
+
+ flushParagraph();
+ flushList();
+ flushBlockquote();
+
+ return out.join("\n");
+}
diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts
index 452c5c8..53c5bc3 100644
--- a/src/lib/plugins/hooks.ts
+++ b/src/lib/plugins/hooks.ts
@@ -15,6 +15,16 @@ export interface PluginHookSet {
}
import { archiveDemoCarbets, seedDemoCarbets } from "./seeds/demo-carbets";
+import {
+ republishContentPages,
+ seedContentPages,
+ unpublishContentPages,
+} from "./seeds/content-pages-default";
+import {
+ republishLegalPages,
+ seedLegalPages,
+ unpublishLegalPages,
+} from "./seeds/legal-pages-default";
export const pluginHooks: Record = {
"demo-carbets-seed": {
@@ -31,4 +41,30 @@ export const pluginHooks: Record = {
);
},
},
+ "content-pages": {
+ onEnable: async () => {
+ const seeded = await seedContentPages();
+ const republished = await republishContentPages();
+ console.log(
+ `[plugin content-pages] seed: ${seeded.created} pages, republished: ${republished}`,
+ );
+ },
+ onDisable: async () => {
+ const unpub = await unpublishContentPages();
+ console.log(`[plugin content-pages] disable: ${unpub} pages dépubliées`);
+ },
+ },
+ "legal-pages": {
+ onEnable: async () => {
+ const seeded = await seedLegalPages();
+ const republished = await republishLegalPages();
+ console.log(
+ `[plugin legal-pages] seed: ${seeded.created} pages, republished: ${republished}`,
+ );
+ },
+ onDisable: async () => {
+ const unpub = await unpublishLegalPages();
+ console.log(`[plugin legal-pages] disable: ${unpub} pages dépubliées`);
+ },
+ },
};
diff --git a/src/lib/plugins/seeds/content-pages-default.ts b/src/lib/plugins/seeds/content-pages-default.ts
new file mode 100644
index 0000000..5ab3cfb
--- /dev/null
+++ b/src/lib/plugins/seeds/content-pages-default.ts
@@ -0,0 +1,185 @@
+/**
+ * Seeds des pages de contenu (plugin content-pages, catégorie "general").
+ */
+
+import { setContentPagePublished, unpublishCategory, upsertContentPage } from "@/lib/content-pages";
+
+type SeedPage = { slug: string; title: string; body: string };
+
+const PAGES: SeedPage[] = [
+ {
+ slug: "a-propos",
+ title: "À propos de Karbé",
+ body: `## Une marketplace solidaire des fleuves de Guyane
+
+Karbé met en relation des propriétaires de carbets fluviaux — particuliers, comités d'entreprise, associations — avec des voyageurs qui cherchent autre chose qu'une chambre d'hôtel : un hamac, un fleuve, du silence.
+
+## Pourquoi Karbé existe
+
+En Guyane, des centaines de carbets dorment six mois par an. Pendant ce temps, des touristes cherchent l'aventure et des CE locaux peinent à proposer des séjours abordables à leurs membres. Karbé fait le pont entre les deux mondes — sans s'enrichir au passage.
+
+## Notre modèle
+
+Le paiement de la réservation transite par Stripe et **est reversé intégralement au propriétaire** : 0 % de commission. Karbé se finance par un abonnement annuel payé par les loueurs qui veulent référencer leur carbet — pas par une prise sur ce que vous payez.
+
+## L'équipe
+
+Karbé est porté par une association numérique guyanaise. Nous sommes basés à Cayenne et nous parlons français, créole, anglais, portugais.
+
+[Devenir loueur](/espace-hote) · [Pour comités d'entreprise](/pour-comites-entreprise) · [Contact](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "comment-ca-marche",
+ title: "Comment ça marche",
+ body: `Karbé permet de réserver un carbet fluvial en trois étapes.
+
+## 1. Trouver le carbet qui vous ressemble
+
+Sur [la page de recherche](/carbets), filtrez par fleuve, dates et nombre de voyageurs. Chaque carbet est classé selon son type d'accès :
+
+- 🛣️ **Route + fleuve** — accessible en voiture, idéal pour un week-end facile
+- 🛶 **Expédition fleuve** — uniquement en pirogue, pour ceux qui veulent vraiment dormir loin
+
+## 2. Réserver et payer
+
+La réservation se fait en ligne, paiement sécurisé via Stripe. Vous recevez une confirmation par e-mail avec les détails d'accès, le point d'embarquement et, si applicable, le contact du passeur.
+
+## 3. Profiter
+
+Le loueur vous remet les clés du karbé (en personne ou via une boîte sécurisée selon le carbet). À votre arrivée, le hamac est là. Le fleuve aussi.
+
+## Et après ?
+
+Une fois rentré, vous pouvez laisser un avis sur votre séjour : il aidera les futurs voyageurs et donnera de la visibilité aux loueurs sérieux.
+
+[Voir tous les carbets disponibles](/carbets)
+`,
+ },
+ {
+ slug: "faq",
+ title: "Questions fréquentes",
+ body: `## Réservation
+
+### Quand mon paiement est-il prélevé ?
+
+Le paiement est autorisé sur votre carte au moment de la réservation et capturé lorsque le loueur confirme la dispo (ou automatiquement dans les 24 h). Si la réservation n'aboutit pas, l'autorisation est libérée sans frais.
+
+### Karbé prend-il une commission ?
+
+**Non.** Le séjour vous est facturé au prix exact fixé par le loueur, qui le reçoit intégralement (moins les frais Stripe). Karbé se finance par un abonnement annuel payé par les loueurs.
+
+### Et si je dois annuler ?
+
+Chaque loueur fixe sa politique d'annulation. Elle est affichée sur la fiche du carbet avant le paiement.
+
+## Sur place
+
+### Comment se passe le transport en pirogue ?
+
+Selon le carbet, le loueur fournit lui-même le passeur, vous oriente vers un prestataire partenaire, ou vous laisse organiser. L'info est précisée sur chaque fiche.
+
+### Les carbets ont-ils l'électricité ? L'eau courante ?
+
+Variable. Beaucoup fonctionnent au solaire et à l'eau de pluie. L'équipement précis figure sur chaque fiche.
+
+### Y a-t-il du réseau ?
+
+Sur les fleuves Maroni, Oyapock et Approuague en amont des bourgs, **non**. Téléchargez vos cartes hors-ligne avant de partir.
+
+## Pour les loueurs
+
+### Combien ça coûte de référencer mon carbet ?
+
+L'abonnement annuel loueur est facturé via Stripe. Le montant exact dépend du nombre de carbets référencés (un seul carbet : tarif standard). Voir [Devenir loueur](/espace-hote) pour les détails.
+
+### Mon CE peut-il référencer son carbet ?
+
+Oui — et c'est encouragé. Les CE peuvent ouvrir leur carbet au public les semaines où aucun membre n'a réservé, tout en gardant la priorité pour leurs adhérents. Voir [Pour comités d'entreprise](/pour-comites-entreprise).
+`,
+ },
+ {
+ slug: "pour-comites-entreprise",
+ title: "Pour comités d'entreprise",
+ body: `## Vos carbets dorment six mois par an. Partageons-les.
+
+De nombreux comités sociaux d'entreprise possèdent déjà un carbet — financé par les cotisations, réservé en priorité aux salariés, mais souvent vide entre deux week-ends. Karbé permet d'ouvrir ces périodes creuses au public, sans commission, sans gestion supplémentaire.
+
+## Comment ça fonctionne
+
+1. Vous référencez votre carbet sur Karbé. C'est gratuit pour les associations et CE conventionnés.
+2. Vous bloquez en priorité les créneaux réservés à vos membres (week-ends, congés scolaires…).
+3. Les créneaux restants sont proposés aux voyageurs publics sur la plateforme.
+4. Le paiement leur est facturé via Stripe et reversé directement au compte du CE.
+
+## Bénéfices
+
+- **Revenus complémentaires** : les locations publiques financent l'entretien.
+- **0 % de commission** : Karbé ne prend rien sur les séjours.
+- **Pas de paperasse** : Stripe encaisse et reverse, sans intermédiaire.
+- **Vos membres gardent la priorité** : un système de double calendrier sépare les créneaux CE des créneaux publics.
+
+## Prochaine étape
+
+Contactez-nous pour discuter de votre cas spécifique (statut du CE, modalités de paiement vers une association, gestion mutualisée de plusieurs carbets) : [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+`,
+ },
+ {
+ slug: "devenir-loueur",
+ title: "Devenir loueur",
+ body: `Vous avez un carbet ? Vous pouvez le proposer sur Karbé en quelques minutes.
+
+## Ce qu'il faut
+
+- Un titre de propriété ou une autorisation explicite du propriétaire
+- Photos du carbet (intérieur, extérieur, point d'embarquement)
+- Description honnête : commodités, accès, contraintes saisonnières
+- Une **politique d'annulation** claire que les voyageurs verront avant de payer
+
+## Étapes
+
+1. [Créez un compte loueur](/connexion) avec votre email et votre numéro de téléphone.
+2. Ajoutez votre carbet : photos, description, coordonnées GPS, point d'embarquement, durée pirogue (si fleuve), équipements.
+3. Ouvrez votre calendrier : indiquez les semaines disponibles, vos contraintes saisonnières (étiage Oyapock ? saison sèche uniquement ?).
+4. Activez l'abonnement annuel via Stripe (paiement sécurisé, prélèvement automatique).
+5. Vous êtes en ligne. Les réservations arrivent par e-mail.
+
+## Le paiement
+
+Quand un voyageur réserve, Stripe encaisse le séjour et **vous le reverse intégralement** sur votre compte bancaire. Karbé ne prend rien sur les séjours.
+
+## Questions
+
+Pour les cas spécifiques (carbet en indivision, parcelle ZAD, accord avec une commune…), écrivez-nous : [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+
+[Créer mon compte loueur](/connexion)
+`,
+ },
+];
+
+export async function seedContentPages(): Promise<{ created: number }> {
+ let created = 0;
+ for (const page of PAGES) {
+ await upsertContentPage({
+ slug: page.slug,
+ title: page.title,
+ body: page.body,
+ category: "general",
+ published: true,
+ });
+ created += 1;
+ }
+ return { created };
+}
+
+export async function unpublishContentPages(): Promise {
+ return await unpublishCategory("general");
+}
+
+export async function republishContentPages(): Promise {
+ let count = 0;
+ for (const page of PAGES) {
+ count += await setContentPagePublished(page.slug, "general", true);
+ }
+ return count;
+}
diff --git a/src/lib/plugins/seeds/legal-pages-default.ts b/src/lib/plugins/seeds/legal-pages-default.ts
new file mode 100644
index 0000000..56f8452
--- /dev/null
+++ b/src/lib/plugins/seeds/legal-pages-default.ts
@@ -0,0 +1,186 @@
+/**
+ * Seeds des pages légales (plugin legal-pages, catégorie "legal").
+ *
+ * Contenu de base, non-conseiller juridique. À faire réviser par un avocat
+ * avant la mise en production réelle.
+ */
+
+import { setContentPagePublished, unpublishCategory, upsertContentPage } from "@/lib/content-pages";
+
+const PAGES: { slug: string; title: string; body: string }[] = [
+ {
+ slug: "cgv",
+ title: "Conditions générales de vente",
+ body: `*Dernière mise à jour : 2026-05-31*
+
+Ces conditions générales régissent l'utilisation de la plateforme Karbé (karbe.cosmolan.fr) opérée par l'association porteuse du projet.
+
+## 1. Objet
+
+Karbé est une plateforme de mise en relation entre propriétaires de carbets fluviaux situés en Guyane française et voyageurs souhaitant louer ces carbets pour des séjours touristiques. Karbé n'est ni propriétaire des carbets ni partie au contrat de location.
+
+## 2. Inscription et comptes
+
+L'inscription est gratuite pour les voyageurs. Pour les loueurs, elle est conditionnée à la souscription d'un abonnement annuel facturé via Stripe.
+
+L'utilisateur s'engage à fournir des informations exactes et à maintenir à jour ses coordonnées.
+
+## 3. Paiement des séjours et reversement
+
+Les séjours sont facturés via Stripe au moment de la réservation. Le montant est **reversé intégralement au loueur** (moins les frais Stripe), sans prélèvement par Karbé.
+
+Karbé ne perçoit aucune commission sur les séjours. Le modèle économique repose exclusivement sur l'abonnement annuel des loueurs.
+
+## 4. Abonnement loueur
+
+L'abonnement annuel est facturé en début de période. Il est tacitement reconduit chaque année sauf résiliation par l'utilisateur depuis son espace.
+
+En cas de résiliation, le carbet est dépublié de la plateforme à la fin de la période en cours. Aucun remboursement prorata n'est effectué.
+
+## 5. Annulations
+
+Chaque loueur fixe sa propre politique d'annulation. Celle-ci est affichée sur la fiche du carbet avant tout paiement.
+
+En cas d'annulation par le voyageur, les conditions du loueur s'appliquent. Karbé peut, en cas de force majeure (fermeture administrative d'un fleuve, par exemple), procéder à un remboursement intégral après instruction.
+
+## 6. Responsabilité
+
+Karbé n'est pas responsable :
+
+- de l'état du carbet ou des équipements fournis par le loueur ;
+- du transport pirogue effectué par le loueur ou un prestataire ;
+- des conditions de navigation (étiage, crue, sécurité fleuve) ;
+- de tout incident survenant pendant le séjour.
+
+Le voyageur reste responsable de sa propre sécurité et est invité à souscrire une assurance voyage adaptée.
+
+## 7. Données personnelles
+
+Les données personnelles collectées sont traitées conformément à la [Politique de confidentialité](/politique-de-confidentialite).
+
+## 8. Droit applicable
+
+Les présentes conditions sont régies par le droit français. Tout litige relève de la compétence du tribunal de Cayenne (Guyane française).
+
+## 9. Contact
+
+[bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "mentions-legales",
+ title: "Mentions légales",
+ body: `*Dernière mise à jour : 2026-05-31*
+
+## Éditeur du site
+
+**Karbé** est édité par l'association porteuse du projet (en cours de constitution), basée à Cayenne (Guyane française).
+
+- Email : [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+- Site : [karbe.cosmolan.fr](https://karbe.cosmolan.fr)
+
+## Hébergement
+
+Le site est hébergé sur des serveurs situés en France métropolitaine, opérés par l'infrastructure Cosmolan.
+
+## Crédits et licences
+
+- Images d'illustration : ressources libres ou produites pour le projet.
+- Code source du site : open source, disponible sur [git.cosmolan.fr/tarzzan/karbe](https://git.cosmolan.fr/tarzzan/karbe).
+
+## Propriété intellectuelle
+
+Le contenu rédactionnel des fiches de carbets appartient aux loueurs qui les ont publiées. La marque et les éléments visuels propres à Karbé restent la propriété de l'éditeur.
+
+## Signalement
+
+Pour signaler un contenu inapproprié ou exercer vos droits (RGPD), écrivez à [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+`,
+ },
+ {
+ slug: "politique-de-confidentialite",
+ title: "Politique de confidentialité",
+ body: `*Dernière mise à jour : 2026-05-31*
+
+Karbé respecte votre vie privée. Cette politique décrit les données que nous collectons, leur usage et vos droits.
+
+## 1. Données collectées
+
+Nous collectons :
+
+- **Données d'identification** : email, nom, prénom, téléphone (renseignés lors de l'inscription).
+- **Données de réservation** : dates, carbet réservé, montant payé.
+- **Données techniques** : adresse IP, type de navigateur, pages consultées (uniquement pour la prévention de fraude et l'analyse d'usage agrégée).
+
+Nous **ne collectons pas** votre carte bancaire : le paiement est délégué à Stripe, qui dispose de sa propre certification PCI-DSS.
+
+## 2. Finalités
+
+- Permettre la mise en relation voyageur ↔ loueur.
+- Facturer et gérer les abonnements loueurs.
+- Prévenir la fraude.
+- Communiquer avec vous concernant votre compte ou vos réservations.
+
+## 3. Base légale
+
+Le traitement repose sur :
+
+- **Le contrat** vous liant à Karbé (compte utilisateur, réservation).
+- **L'obligation légale** pour les données comptables.
+- **L'intérêt légitime** pour la prévention de fraude.
+
+## 4. Durée de conservation
+
+- Compte actif : tant que vous l'utilisez.
+- Données comptables : 10 ans (obligation légale).
+- Journaux techniques : 12 mois maximum.
+
+## 5. Destinataires
+
+Vos données sont accessibles :
+
+- À l'équipe Karbé strictement dans le cadre de ses missions.
+- À Stripe pour le traitement des paiements.
+- Au loueur pour les besoins de votre séjour (nom, contact).
+
+Aucune donnée n'est revendue ni partagée avec des tiers à des fins publicitaires.
+
+## 6. Vos droits
+
+Conformément au RGPD, vous disposez d'un droit d'accès, de rectification, d'effacement, de portabilité et d'opposition. Pour les exercer, écrivez à [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+
+Vous pouvez également déposer une réclamation auprès de la CNIL ([www.cnil.fr](https://www.cnil.fr)).
+
+## 7. Cookies
+
+Karbé utilise uniquement des cookies techniques (session, préférences de langue). Aucun cookie publicitaire ni cookie tiers de tracking n'est déposé.
+`,
+ },
+];
+
+export async function seedLegalPages(): Promise<{ created: number }> {
+ let created = 0;
+ for (const page of PAGES) {
+ await upsertContentPage({
+ slug: page.slug,
+ title: page.title,
+ body: page.body,
+ category: "legal",
+ published: true,
+ });
+ created += 1;
+ }
+ return { created };
+}
+
+export async function unpublishLegalPages(): Promise {
+ return await unpublishCategory("legal");
+}
+
+export async function republishLegalPages(): Promise {
+ let count = 0;
+ for (const page of PAGES) {
+ count += await setContentPagePublished(page.slug, "legal", true);
+ }
+ return count;
+}
From a174f99ebae3c39fcbf1d1b38842d442f27f8279 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Sun, 31 May 2026 11:29:29 +0000
Subject: [PATCH 19/77] feat(plugin): pirogue-providers (Phase 3.3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Modèle PirogueProvider (id, name, contacts, fleuves, tarif, description)
+ enum TransportMode (OWNER_PROVIDES, SELF_ARRANGE, PARTNER_PROVIDER) sur Carbet
+ relation Carbet → PirogueProvider (nullable, ondelete:SetNull)
Composants :
- PirogueTransportBlock (server, gated par plugin) sur fiche carbet :
affiche le mode + provider partenaire avec contacts/tarif/description
- Page publique /partenaires-pirogue : liste des partenaires actifs
Seed onEnable :
- 3 partenaires démo (Pirogues du Maroni, Approuague Aventures, Oyapock Frontière)
avec tarifs estimatifs et fleuves desservis réels
- Attribution aux 6 carbets démo :
· Awara (Maroni), Maripa (Approuague), Paripou (Oyapock) → PARTNER_PROVIDER
· Wapa (Comté), Mahury CE → OWNER_PROVIDES
· Kourou Couleuvre → SELF_ARRANGE
onDisable désactive les partenaires démo et détache les carbets démo.
---
.../migration.sql | 29 +++++
prisma/schema.prisma | 40 ++++++-
src/app/carbets/[slug]/page.tsx | 6 +
src/app/partenaires-pirogue/page.tsx | 88 +++++++++++++++
src/components/PirogueTransportBlock.tsx | 80 ++++++++++++++
src/lib/carbet-public.ts | 32 +++++-
src/lib/pirogue-providers.ts | 50 +++++++++
src/lib/plugins/hooks.ts | 16 +++
.../seeds/pirogue-providers-default.ts | 104 ++++++++++++++++++
9 files changed, 438 insertions(+), 7 deletions(-)
create mode 100644 prisma/migrations/20260531200000_add_pirogue_providers/migration.sql
create mode 100644 src/app/partenaires-pirogue/page.tsx
create mode 100644 src/components/PirogueTransportBlock.tsx
create mode 100644 src/lib/pirogue-providers.ts
create mode 100644 src/lib/plugins/seeds/pirogue-providers-default.ts
diff --git a/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql b/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql
new file mode 100644
index 0000000..3f25550
--- /dev/null
+++ b/prisma/migrations/20260531200000_add_pirogue_providers/migration.sql
@@ -0,0 +1,29 @@
+-- Plugin pirogue-providers : modèle PirogueProvider + transportMode sur Carbet
+
+CREATE TYPE "TransportMode" AS ENUM ('OWNER_PROVIDES', 'SELF_ARRANGE', 'PARTNER_PROVIDER');
+
+CREATE TABLE "PirogueProvider" (
+ "id" TEXT PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "contactEmail" TEXT,
+ "contactPhone" TEXT,
+ "rivers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
+ "pricingNote" TEXT,
+ "description" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL
+);
+
+CREATE INDEX "PirogueProvider_active_idx" ON "PirogueProvider" ("active");
+
+ALTER TABLE "Carbet"
+ ADD COLUMN "transportMode" "TransportMode",
+ ADD COLUMN "pirogueProviderId" TEXT;
+
+ALTER TABLE "Carbet"
+ ADD CONSTRAINT "Carbet_pirogueProviderId_fkey"
+ FOREIGN KEY ("pirogueProviderId") REFERENCES "PirogueProvider"("id")
+ ON DELETE SET NULL;
+
+CREATE INDEX "Carbet_pirogueProviderId_idx" ON "Carbet" ("pirogueProviderId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4fd694e..5a05f4f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -64,6 +64,12 @@ enum AccessType {
RIVER_ONLY
}
+enum TransportMode {
+ OWNER_PROVIDES
+ SELF_ARRANGE
+ PARTNER_PROVIDER
+}
+
model Organization {
id String @id @default(cuid())
name String
@@ -125,23 +131,45 @@ model Carbet {
// Contraintes saisonnières (plugin seasonality). JSON libre, schéma type :
// { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string }
seasonalConstraints Json?
+ // Plugin pirogue-providers : qui organise le transport ?
+ transportMode TransportMode?
+ pirogueProviderId String?
status CarbetStatus @default(DRAFT)
lastBookedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
- amenities CarbetAmenity[]
- media Media[]
- availabilities Availability[]
- bookings Booking[]
- reviews Review[]
+ owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
+ pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
+ amenities CarbetAmenity[]
+ media Media[]
+ availabilities Availability[]
+ bookings Booking[]
+ reviews Review[]
subscriptions Subscription[]
@@index([ownerId])
@@index([status])
@@index([river])
@@index([accessType])
+ @@index([pirogueProviderId])
+}
+
+model PirogueProvider {
+ id String @id @default(cuid())
+ name String
+ contactEmail String?
+ contactPhone String?
+ rivers String[] @default([])
+ pricingNote String?
+ description String?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ carbets Carbet[]
+
+ @@index([active])
}
model Amenity {
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
index 227b305..9a3e51c 100644
--- a/src/app/carbets/[slug]/page.tsx
+++ b/src/app/carbets/[slug]/page.tsx
@@ -17,6 +17,7 @@ import { ReviewsSection } from "../_components/reviews-section";
import { StarRating } from "../_components/star-rating";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
import { StayConstraints } from "@/components/StayConstraints";
+import { PirogueTransportBlock } from "@/components/PirogueTransportBlock";
type PageProps = {
params: Promise<{ slug: string }>;
@@ -137,6 +138,11 @@ export default async function PublicCarbetPage({ params }: PageProps) {
+
+
{carbet.amenities.length > 0 ? (
diff --git a/src/app/partenaires-pirogue/page.tsx b/src/app/partenaires-pirogue/page.tsx
new file mode 100644
index 0000000..812be29
--- /dev/null
+++ b/src/app/partenaires-pirogue/page.tsx
@@ -0,0 +1,88 @@
+import { notFound } from "next/navigation";
+import { isPluginEnabled } from "@/lib/plugins/server";
+import { listActiveProviders } from "@/lib/pirogue-providers";
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata() {
+ return { title: "Partenaires pirogue" };
+}
+
+export default async function ProvidersPage() {
+ if (!(await isPluginEnabled("pirogue-providers"))) notFound();
+ const providers = await listActiveProviders();
+
+ return (
+
+
+
+ Transport
+
+
+ Nos partenaires pirogue
+
+
+ Pour les carbets accessibles uniquement par le fleuve, on travaille avec des piroguiers
+ locaux référencés. Tarifs estimatifs ci-dessous ; le détail de votre trajet est calé
+ directement avec le partenaire après réservation.
+
+
+
+ {providers.length === 0 ? (
+
+ Aucun partenaire référencé pour le moment.
+
+ ) : (
+
+ {providers.map((p) => (
+ -
+
+
{p.name}
+
+ {p.rivers.map((r) => (
+
+ {r}
+
+ ))}
+
+
+ {p.description ? (
+ {p.description}
+ ) : null}
+ {p.pricingNote ? (
+ {p.pricingNote}
+ ) : null}
+
+ {p.contactEmail ? (
+
+ ) : null}
+ {p.contactPhone ? (
+
+
- Tél. ·
+ - {p.contactPhone}
+
+ ) : null}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/PirogueTransportBlock.tsx b/src/components/PirogueTransportBlock.tsx
new file mode 100644
index 0000000..149d75e
--- /dev/null
+++ b/src/components/PirogueTransportBlock.tsx
@@ -0,0 +1,80 @@
+import { isPluginEnabled } from "@/lib/plugins/server";
+import {
+ TRANSPORT_MODE_EMOJI,
+ TRANSPORT_MODE_LABEL,
+ type PirogueProvider,
+} from "@/lib/pirogue-providers";
+
+/**
+ * Bloc transport pirogue sur la fiche carbet (server component).
+ * Gated par le plugin `pirogue-providers`. Sans le plugin, retourne null.
+ */
+export async function PirogueTransportBlock({
+ transportMode,
+ provider,
+}: {
+ transportMode: string | null;
+ provider: PirogueProvider | null;
+}) {
+ if (!(await isPluginEnabled("pirogue-providers"))) return null;
+ if (!transportMode) return null;
+
+ const emoji = TRANSPORT_MODE_EMOJI[transportMode] ?? "🛶";
+ const label = TRANSPORT_MODE_LABEL[transportMode] ?? "Transport pirogue";
+
+ return (
+
+
+ {emoji}
+
+ Transport pirogue — {label}
+
+
+
+ {transportMode === "PARTNER_PROVIDER" && provider ? (
+
+
+ Ce carbet travaille avec un partenaire référencé :{" "}
+ {provider.name}
+
+ {provider.description ?
{provider.description}
: null}
+ {provider.pricingNote ? (
+
{provider.pricingNote}
+ ) : null}
+
+ {provider.contactEmail ? (
+
+ ) : null}
+ {provider.contactPhone ? (
+
+
- Tél. ·
+ - {provider.contactPhone}
+
+ ) : null}
+
+
+ ) : transportMode === "OWNER_PROVIDES" ? (
+
+ Le loueur s'occupe du transport : il vous récupère au point d'embarquement et
+ vous ramène en fin de séjour. Détails de l'heure et du point de rendez-vous transmis
+ par e-mail après réservation.
+
+ ) : (
+
+ Le transport est à votre charge. Renseignez-vous auprès des piroguiers locaux du dégrad
+ d'embarquement, ou prévenez-nous : on peut vous orienter vers un partenaire.
+
+ )}
+
+ );
+}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
index dc32e1f..82ed693 100644
--- a/src/lib/carbet-public.ts
+++ b/src/lib/carbet-public.ts
@@ -2,12 +2,13 @@ import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { amenityLabel } from "@/lib/amenities";
-import { AccessType, CarbetStatus, MediaType } from "@/generated/prisma/enums";
+import { AccessType, CarbetStatus, MediaType, TransportMode } from "@/generated/prisma/enums";
import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
import {
getCarbetReviewStats,
listCarbetReviews,
} from "@/lib/reviews-server";
+import type { PirogueProvider } from "@/lib/pirogue-providers";
export type PublicCarbetMedia = {
id: string;
@@ -30,6 +31,8 @@ export type PublicCarbetDetail = {
maxStayNights: number | null;
minCapacity: number | null;
seasonalConstraints: unknown;
+ transportMode: TransportMode | null;
+ pirogueProvider: PirogueProvider | null;
latitude: string;
longitude: string;
ownerId: string;
@@ -61,10 +64,24 @@ export const getPublicCarbet = cache(
maxStayNights: true,
minCapacity: true,
seasonalConstraints: true,
+ transportMode: true,
+ pirogueProviderId: true,
latitude: true,
longitude: true,
ownerId: true,
owner: { select: { firstName: true } },
+ pirogueProvider: {
+ select: {
+ id: true,
+ name: true,
+ contactEmail: true,
+ contactPhone: true,
+ rivers: true,
+ pricingNote: true,
+ description: true,
+ active: true,
+ },
+ },
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true },
@@ -97,6 +114,19 @@ export const getPublicCarbet = cache(
maxStayNights: carbet.maxStayNights,
minCapacity: carbet.minCapacity,
seasonalConstraints: carbet.seasonalConstraints,
+ transportMode: carbet.transportMode,
+ pirogueProvider: carbet.pirogueProvider
+ ? {
+ id: carbet.pirogueProvider.id,
+ name: carbet.pirogueProvider.name,
+ contactEmail: carbet.pirogueProvider.contactEmail,
+ contactPhone: carbet.pirogueProvider.contactPhone,
+ rivers: carbet.pirogueProvider.rivers,
+ pricingNote: carbet.pirogueProvider.pricingNote,
+ description: carbet.pirogueProvider.description,
+ active: carbet.pirogueProvider.active,
+ }
+ : null,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
ownerId: carbet.ownerId,
diff --git a/src/lib/pirogue-providers.ts b/src/lib/pirogue-providers.ts
new file mode 100644
index 0000000..923cdd0
--- /dev/null
+++ b/src/lib/pirogue-providers.ts
@@ -0,0 +1,50 @@
+/**
+ * Helpers Plugin pirogue-providers.
+ */
+
+import { prisma } from "@/lib/prisma";
+
+export type PirogueProvider = {
+ id: string;
+ name: string;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ rivers: string[];
+ pricingNote: string | null;
+ description: string | null;
+ active: boolean;
+};
+
+export async function listActiveProviders(): Promise {
+ const rows = await prisma.pirogueProvider.findMany({
+ where: { active: true },
+ orderBy: { name: "asc" },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ contactEmail: r.contactEmail,
+ contactPhone: r.contactPhone,
+ rivers: r.rivers,
+ pricingNote: r.pricingNote,
+ description: r.description,
+ active: r.active,
+ }));
+}
+
+export async function listProvidersForRiver(river: string): Promise {
+ const all = await listActiveProviders();
+ return all.filter((p) => p.rivers.some((r) => r.toLowerCase() === river.toLowerCase()));
+}
+
+export const TRANSPORT_MODE_LABEL: Record = {
+ OWNER_PROVIDES: "Le loueur fournit le passeur",
+ SELF_ARRANGE: "À organiser par le voyageur",
+ PARTNER_PROVIDER: "Partenaire référencé",
+};
+
+export const TRANSPORT_MODE_EMOJI: Record = {
+ OWNER_PROVIDES: "👤",
+ SELF_ARRANGE: "🗺️",
+ PARTNER_PROVIDER: "🤝",
+};
diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts
index 53c5bc3..d9cccb7 100644
--- a/src/lib/plugins/hooks.ts
+++ b/src/lib/plugins/hooks.ts
@@ -25,6 +25,10 @@ import {
seedLegalPages,
unpublishLegalPages,
} from "./seeds/legal-pages-default";
+import {
+ deactivatePirogueProviders,
+ seedPirogueProviders,
+} from "./seeds/pirogue-providers-default";
export const pluginHooks: Record = {
"demo-carbets-seed": {
@@ -67,4 +71,16 @@ export const pluginHooks: Record = {
console.log(`[plugin legal-pages] disable: ${unpub} pages dépubliées`);
},
},
+ "pirogue-providers": {
+ onEnable: async () => {
+ const { providers, carbets } = await seedPirogueProviders();
+ console.log(
+ `[plugin pirogue-providers] seed: ${providers} partenaires, ${carbets} carbets attachés`,
+ );
+ },
+ onDisable: async () => {
+ const count = await deactivatePirogueProviders();
+ console.log(`[plugin pirogue-providers] disable: ${count} partenaires désactivés`);
+ },
+ },
};
diff --git a/src/lib/plugins/seeds/pirogue-providers-default.ts b/src/lib/plugins/seeds/pirogue-providers-default.ts
new file mode 100644
index 0000000..9360325
--- /dev/null
+++ b/src/lib/plugins/seeds/pirogue-providers-default.ts
@@ -0,0 +1,104 @@
+/**
+ * Seed du plugin `pirogue-providers` : 3 prestataires partenaires fictifs
+ * réalistes pour la démo, attachés à 4 fleuves majeurs de Guyane.
+ */
+
+import { prisma } from "@/lib/prisma";
+
+const PROVIDERS = [
+ {
+ id: "demo-provider-maroni",
+ name: "Pirogues du Maroni",
+ contactEmail: "contact@pirogues-maroni.demo",
+ contactPhone: "+594-694-100200",
+ rivers: ["Maroni", "Lawa"],
+ pricingNote: "≈ 150 € aller-retour depuis Apatou (selon distance carbet)",
+ description:
+ "Coopérative de piroguiers bushinengués. Aller-retour vers les carbets en aval d'Apatou, départs matin. Capacité 6-8 passagers, vestes de pluie fournies.",
+ },
+ {
+ id: "demo-provider-approuague",
+ name: "Approuague Aventures",
+ contactEmail: "info@approuague-aventures.demo",
+ contactPhone: "+594-694-300400",
+ rivers: ["Approuague"],
+ pricingNote: "≈ 250 € aller-retour Régina, ≈ 320 € jusqu'au saut Mapaou",
+ description:
+ "Prestataire historique de l'Approuague, basé à Régina. Pirogues 4 passagers + matériel. Possibilité de jour blanc (pêche, observation) en option.",
+ },
+ {
+ id: "demo-provider-oyapock",
+ name: "Oyapock Frontière",
+ contactEmail: "contact@oyapock-frontiere.demo",
+ contactPhone: "+594-694-500600",
+ rivers: ["Oyapock"],
+ pricingNote: "≈ 300 € aller-retour Saint-Georges (haute eau), tarif majoré en étiage",
+ description:
+ "Trajet vers les carbets côté Guyane et côté Brésilien (Vila Brasil). En étiage (oct-nov), prévoir une marge horaire — fond du fleuve parfois imprévisible.",
+ },
+] as const;
+
+const CARBET_PROVIDER_LINKS = [
+ { slug: "demo-karbe-awara-maroni", providerId: "demo-provider-maroni", mode: "PARTNER_PROVIDER" as const },
+ { slug: "demo-karbe-maripa-approuague", providerId: "demo-provider-approuague", mode: "PARTNER_PROVIDER" as const },
+ { slug: "demo-karbe-paripou-oyapock", providerId: "demo-provider-oyapock", mode: "PARTNER_PROVIDER" as const },
+ { slug: "demo-karbe-wapa-comte", providerId: null, mode: "OWNER_PROVIDES" as const },
+ { slug: "demo-karbe-mahury-ce-hopital", providerId: null, mode: "OWNER_PROVIDES" as const },
+ { slug: "demo-karbe-kourou-couleuvre", providerId: null, mode: "SELF_ARRANGE" as const },
+];
+
+export async function seedPirogueProviders(): Promise<{ providers: number; carbets: number }> {
+ let providers = 0;
+ for (const p of PROVIDERS) {
+ await prisma.pirogueProvider.upsert({
+ where: { id: p.id },
+ update: {
+ name: p.name,
+ contactEmail: p.contactEmail,
+ contactPhone: p.contactPhone,
+ rivers: [...p.rivers],
+ pricingNote: p.pricingNote,
+ description: p.description,
+ active: true,
+ },
+ create: {
+ id: p.id,
+ name: p.name,
+ contactEmail: p.contactEmail,
+ contactPhone: p.contactPhone,
+ rivers: [...p.rivers],
+ pricingNote: p.pricingNote,
+ description: p.description,
+ active: true,
+ },
+ });
+ providers += 1;
+ }
+
+ let carbets = 0;
+ for (const link of CARBET_PROVIDER_LINKS) {
+ const updated = await prisma.carbet.updateMany({
+ where: { slug: link.slug },
+ data: {
+ pirogueProviderId: link.providerId,
+ transportMode: link.mode,
+ },
+ });
+ carbets += updated.count;
+ }
+
+ return { providers, carbets };
+}
+
+export async function deactivatePirogueProviders(): Promise {
+ const result = await prisma.pirogueProvider.updateMany({
+ where: { id: { startsWith: "demo-provider-" } },
+ data: { active: false },
+ });
+ // Détache aussi les carbets démo
+ await prisma.carbet.updateMany({
+ where: { pirogueProviderId: { startsWith: "demo-provider-" } },
+ data: { pirogueProviderId: null, transportMode: null },
+ });
+ return result.count;
+}
From cf9da94bb5de14a03e2d4e3069a09fca78c5616a Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Sun, 31 May 2026 11:38:39 +0000
Subject: [PATCH 20/77] 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 (
-