feat(plugins): content-pages + legal-pages (Phase 4.1 + 4.3)

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)
This commit is contained in:
Claude Integration 2026-05-31 10:12:13 +00:00
parent ae8f79b436
commit 68f37f554f
20 changed files with 1116 additions and 0 deletions

View file

@ -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");

View file

@ -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])
}

18
src/app/a-propos/page.tsx Normal file
View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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<string | null>(null);
const [err, setErr] = useState<string | null>(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 (
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-gray-700">Titre</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700">
Contenu (markdown léger : # ## ### gras italique [link](url) listes - 1. ---)
</span>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={24}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm leading-relaxed"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
/>
Publié
</label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={save}
disabled={busy}
className="rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white hover:bg-gray-800 disabled:opacity-50"
>
{busy ? "Sauvegarde…" : "Sauvegarder"}
</button>
{msg ? <span className="text-sm text-green-700">{msg}</span> : null}
{err ? <span className="text-sm text-red-700">{err}</span> : null}
</div>
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Link
href="/admin/content-pages"
className="text-sm text-gray-600 hover:text-gray-900"
>
Toutes les pages
</Link>
<h1 className="mt-3 text-2xl font-semibold">Éditer · {page.title}</h1>
<p className="mt-1 text-sm text-gray-600">
URL publique : <code>/{page.slug}</code>
</p>
<div className="mt-6">
<EditorForm page={page} />
</div>
</div>
);
}

View file

@ -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<string, string> = {
general: "Général",
legal: "Légales",
};
export default async function ContentPagesAdminPage() {
await requireRole([UserRole.ADMIN]);
const pages = await listContentPages();
const byCategory = pages.reduce<Record<string, typeof pages>>((acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
}, {});
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold">Pages éditoriales</h1>
<p className="mt-2 text-sm text-gray-600">
Pages markdown affichées dans le site public. La catégorie « Général »
est gérée par le plugin <code>content-pages</code>, la catégorie « Légales »
par <code>legal-pages</code>. Désactiver le plugin dépublie ses pages
sans les supprimer.
</p>
<div className="mt-6 space-y-8">
{Object.entries(byCategory).map(([cat, list]) => (
<section key={cat}>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
{CATEGORY_LABEL[cat] ?? cat}
</h2>
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{list.map((p) => (
<li key={p.slug} className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<div className="font-medium">{p.title}</div>
<div className="text-xs text-gray-500">
<code>/{p.slug}</code> · {p.published ? "publié" : "dépublié"} ·
mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")}
</div>
</div>
<Link
href={`/admin/content-pages/${encodeURIComponent(p.slug)}`}
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold text-white hover:bg-gray-800"
>
Éditer
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</div>
);
}

View file

@ -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,
});
}

18
src/app/cgv/page.tsx Normal file
View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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 <ContentPageRenderer page={page} />;
}

18
src/app/faq/page.tsx Normal file
View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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 <ContentPageRenderer page={page} />;
}

View file

@ -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 (
<article className="mx-auto max-w-3xl px-6 py-12 sm:px-8 lg:px-12">
<header className="mb-8">
<h1 className="font-serif text-4xl font-medium tracking-tight text-[var(--color-karbe-ink)] sm:text-5xl">
{page.title}
</h1>
<p className="mt-2 text-xs uppercase tracking-wider text-[var(--color-karbe-canopy-700)]/70">
Mis à jour le{" "}
{new Date(page.updatedAt).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
</header>
<div
className="text-base leading-relaxed text-[var(--color-karbe-ink)]/90"
dangerouslySetInnerHTML={{ __html: html }}
/>
</article>
);
}

110
src/lib/content-pages.ts Normal file
View file

@ -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<ContentPage | null> {
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<ContentPage[]> {
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<ContentPage> {
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<number> {
const result = await prisma.contentPage.updateMany({
where: { slug, category },
data: { published },
});
return result.count;
}
export async function unpublishCategory(category: string): Promise<number> {
const result = await prisma.contentPage.updateMany({
where: { category },
data: { published: false },
});
return result.count;
}

151
src/lib/markdown.ts Normal file
View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function inline(text: string): string {
let out = escapeHtml(text);
// **bold**
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// *italic*
out = out.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
// [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 `<a href="${safe}" class="text-[var(--color-karbe-canopy-700)] underline hover:text-[var(--color-karbe-laterite-700)]"${extra}>${label}</a>`;
},
);
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(`<p class="my-4 leading-relaxed">${inline(paragraph.join(" "))}</p>`);
paragraph = [];
}
}
function flushList() {
if (listType) {
out.push(`</${listType}>`);
listType = null;
}
}
function flushBlockquote() {
if (inBlockquote) {
out.push(
`<blockquote class="my-4 border-l-4 border-[var(--color-karbe-laterite-500)] pl-4 italic text-[var(--color-karbe-ink)]/75">${blockquote
.map((l) => inline(l))
.join(" ")}</blockquote>`,
);
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(`<hr class="my-6 border-[var(--color-karbe-canopy-100)]" />`);
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(`<h${level} class="${sizes[level]}">${inline(m[2])}</h${level}>`);
continue;
}
if ((m = /^[-*]\s+(.+)$/.exec(line))) {
flushParagraph();
flushBlockquote();
if (listType !== "ul") {
flushList();
out.push(`<ul class="my-4 list-disc space-y-1 pl-6">`);
listType = "ul";
}
out.push(`<li>${inline(m[1])}</li>`);
continue;
}
if ((m = /^\d+\.\s+(.+)$/.exec(line))) {
flushParagraph();
flushBlockquote();
if (listType !== "ol") {
flushList();
out.push(`<ol class="my-4 list-decimal space-y-1 pl-6">`);
listType = "ol";
}
out.push(`<li>${inline(m[1])}</li>`);
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");
}

View file

@ -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<string, PluginHookSet | undefined> = {
"demo-carbets-seed": {
@ -31,4 +41,30 @@ export const pluginHooks: Record<string, PluginHookSet | undefined> = {
);
},
},
"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`);
},
},
};

View file

@ -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 . 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<number> {
return await unpublishCategory("general");
}
export async function republishContentPages(): Promise<number> {
let count = 0;
for (const page of PAGES) {
count += await setContentPagePublished(page.slug, "general", true);
}
return count;
}

View file

@ -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<number> {
return await unpublishCategory("legal");
}
export async function republishLegalPages(): Promise<number> {
let count = 0;
for (const page of PAGES) {
count += await setContentPagePublished(page.slug, "legal", true);
}
return count;
}