diff --git a/prisma/migrations/20260531220000_content_page_composite_key/migration.sql b/prisma/migrations/20260531220000_content_page_composite_key/migration.sql
new file mode 100644
index 0000000..c3ccfd9
--- /dev/null
+++ b/prisma/migrations/20260531220000_content_page_composite_key/migration.sql
@@ -0,0 +1,8 @@
+-- Plugin i18n-fr-en + content-pages :
+-- ContentPage devient bilingue → PK composite (slug, lang)
+-- pour pouvoir stocker une version FR et une version EN du même slug.
+
+ALTER TABLE "ContentPage" DROP CONSTRAINT "ContentPage_pkey";
+ALTER TABLE "ContentPage" ADD CONSTRAINT "ContentPage_pkey" PRIMARY KEY ("slug", "lang");
+
+CREATE INDEX IF NOT EXISTS "ContentPage_slug_idx" ON "ContentPage" ("slug");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5a05f4f..63a3b1c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -310,10 +310,10 @@ model Plugin {
}
model ContentPage {
- slug String @id
+ slug String
+ lang String @default("fr")
title String
body String
- lang String @default("fr")
// 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
category String @default("general")
published Boolean @default(true)
@@ -321,6 +321,8 @@ model ContentPage {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ @@id([slug, lang])
+ @@index([slug])
@@index([category])
@@index([published])
}
diff --git a/src/app/a-propos/page.tsx b/src/app/a-propos/page.tsx
index e4a36b1..3446a4b 100644
--- a/src/app/a-propos/page.tsx
+++ b/src/app/a-propos/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("a-propos", await getLocale());
return { title: page?.title ?? "À propos" };
}
export default async function AboutPage() {
if (!(await isPluginEnabled("content-pages"))) notFound();
- const page = await getContentPage("a-propos");
+ const page = await getContentPage("a-propos", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/cgv/page.tsx b/src/app/cgv/page.tsx
index eaec1d2..32f9693 100644
--- a/src/app/cgv/page.tsx
+++ b/src/app/cgv/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("cgv", await getLocale());
return { title: page?.title ?? "CGV" };
}
export default async function CgvPage() {
if (!(await isPluginEnabled("legal-pages"))) notFound();
- const page = await getContentPage("cgv");
+ const page = await getContentPage("cgv", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/comment-ca-marche/page.tsx b/src/app/comment-ca-marche/page.tsx
index bae2149..e9163e0 100644
--- a/src/app/comment-ca-marche/page.tsx
+++ b/src/app/comment-ca-marche/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("comment-ca-marche", await getLocale());
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");
+ const page = await getContentPage("comment-ca-marche", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/devenir-loueur/page.tsx b/src/app/devenir-loueur/page.tsx
index c99d3ff..e3ca697 100644
--- a/src/app/devenir-loueur/page.tsx
+++ b/src/app/devenir-loueur/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("devenir-loueur", await getLocale());
return { title: page?.title ?? "Devenir loueur" };
}
export default async function OwnerOnboardingPage() {
if (!(await isPluginEnabled("content-pages"))) notFound();
- const page = await getContentPage("devenir-loueur");
+ const page = await getContentPage("devenir-loueur", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx
index 9de53b1..5583b28 100644
--- a/src/app/faq/page.tsx
+++ b/src/app/faq/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("faq", await getLocale());
return { title: page?.title ?? "FAQ" };
}
export default async function FaqPage() {
if (!(await isPluginEnabled("content-pages"))) notFound();
- const page = await getContentPage("faq");
+ const page = await getContentPage("faq", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/mentions-legales/page.tsx b/src/app/mentions-legales/page.tsx
index ecd2a43..1eeb699 100644
--- a/src/app/mentions-legales/page.tsx
+++ b/src/app/mentions-legales/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("mentions-legales", await getLocale());
return { title: page?.title ?? "Mentions légales" };
}
export default async function MentionsPage() {
if (!(await isPluginEnabled("legal-pages"))) notFound();
- const page = await getContentPage("mentions-legales");
+ const page = await getContentPage("mentions-legales", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/politique-de-confidentialite/page.tsx b/src/app/politique-de-confidentialite/page.tsx
index 7271d29..8db657d 100644
--- a/src/app/politique-de-confidentialite/page.tsx
+++ b/src/app/politique-de-confidentialite/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("politique-de-confidentialite", await getLocale());
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");
+ const page = await getContentPage("politique-de-confidentialite", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/app/pour-comites-entreprise/page.tsx b/src/app/pour-comites-entreprise/page.tsx
index 9ad839e..f0125bd 100644
--- a/src/app/pour-comites-entreprise/page.tsx
+++ b/src/app/pour-comites-entreprise/page.tsx
@@ -1,18 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
+import { getLocale } from "@/lib/i18n/server";
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");
+ const page = await getContentPage("pour-comites-entreprise", await getLocale());
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");
+ const page = await getContentPage("pour-comites-entreprise", await getLocale());
if (!page) notFound();
return ;
}
diff --git a/src/lib/content-pages.ts b/src/lib/content-pages.ts
index 442ed56..e789382 100644
--- a/src/lib/content-pages.ts
+++ b/src/lib/content-pages.ts
@@ -2,8 +2,9 @@
* 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").
+ * table DB. Clé composite (slug, lang) : une page peut exister en plusieurs
+ * langues. getContentPage(slug, lang) fait un fallback sur 'fr' si la version
+ * dans la langue demandée n'existe pas ou n'est pas publiée.
*/
import { prisma } from "@/lib/prisma";
@@ -18,25 +19,49 @@ export type ContentPage = {
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 getContentPage(slug: string, lang: string = "fr"): Promise {
+ // Essai dans la langue demandée
+ const row = await prisma.contentPage.findUnique({
+ where: { slug_lang: { slug, lang } },
+ });
+ if (row && row.published) {
+ return {
+ slug: row.slug,
+ title: row.title,
+ body: row.body,
+ lang: row.lang,
+ category: row.category,
+ published: row.published,
+ updatedAt: row.updatedAt,
+ };
+ }
+ // Fallback FR si autre langue manquante
+ if (lang !== "fr") {
+ const fallback = await prisma.contentPage.findUnique({
+ where: { slug_lang: { slug, lang: "fr" } },
+ });
+ if (fallback && fallback.published) {
+ return {
+ slug: fallback.slug,
+ title: fallback.title,
+ body: fallback.body,
+ lang: fallback.lang,
+ category: fallback.category,
+ published: fallback.published,
+ updatedAt: fallback.updatedAt,
+ };
+ }
+ }
+ return null;
}
-export async function listContentPages(category?: string): Promise {
+export async function listContentPages(category?: string, lang?: string): Promise {
+ const where: { category?: string; lang?: string } = {};
+ if (category) where.category = category;
+ if (lang) where.lang = lang;
const rows = await prisma.contentPage.findMany({
- where: category ? { category } : undefined,
- orderBy: [{ category: "asc" }, { slug: "asc" }],
+ where,
+ orderBy: [{ category: "asc" }, { slug: "asc" }, { lang: "asc" }],
});
return rows.map((r) => ({
slug: r.slug,
@@ -58,22 +83,22 @@ export async function upsertContentPage(input: {
published?: boolean;
lastEditedBy?: string;
}): Promise {
+ const lang = input.lang ?? "fr";
const row = await prisma.contentPage.upsert({
- where: { slug: input.slug },
+ where: { slug_lang: { slug: input.slug, lang } },
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,
+ lang,
title: input.title,
body: input.body,
category: input.category ?? "general",
- lang: input.lang ?? "fr",
published: input.published ?? true,
lastEditedBy: input.lastEditedBy ?? null,
},
@@ -93,9 +118,10 @@ export async function setContentPagePublished(
slug: string,
category: string,
published: boolean,
+ lang?: string,
): Promise {
const result = await prisma.contentPage.updateMany({
- where: { slug, category },
+ where: lang ? { slug, category, lang } : { slug, category },
data: { published },
});
return result.count;
diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts
index d9cccb7..0bbedbd 100644
--- a/src/lib/plugins/hooks.ts
+++ b/src/lib/plugins/hooks.ts
@@ -29,6 +29,7 @@ import {
deactivatePirogueProviders,
seedPirogueProviders,
} from "./seeds/pirogue-providers-default";
+import { seedEnglishContentPages } from "./seeds/content-pages-en";
export const pluginHooks: Record = {
"demo-carbets-seed": {
@@ -83,4 +84,13 @@ export const pluginHooks: Record = {
console.log(`[plugin pirogue-providers] disable: ${count} partenaires désactivés`);
},
},
+ // Quand i18n est activé, on seed les pages content + legal en EN.
+ // Désactiver n'efface pas les EN pages (elles dorment juste, fallback FR
+ // reprend la main au prochain getContentPage).
+ "i18n-fr-en": {
+ onEnable: async () => {
+ const count = await seedEnglishContentPages();
+ console.log(`[plugin i18n-fr-en] seed: ${count} pages EN`);
+ },
+ },
};
diff --git a/src/lib/plugins/seeds/content-pages-en.ts b/src/lib/plugins/seeds/content-pages-en.ts
new file mode 100644
index 0000000..fbe190c
--- /dev/null
+++ b/src/lib/plugins/seeds/content-pages-en.ts
@@ -0,0 +1,282 @@
+/**
+ * Seeds des pages content + legal en anglais.
+ * Activé conjointement avec le plugin `content-pages` / `legal-pages` quand
+ * `i18n-fr-en` est aussi enabled — sinon dort en DB sans être servi.
+ */
+
+import { upsertContentPage } from "@/lib/content-pages";
+
+const PAGES_EN = [
+ {
+ slug: "a-propos",
+ category: "general",
+ title: "About Karbé",
+ body: `## A solidarity marketplace for French Guiana's rivers
+
+Karbé connects owners of riverside carbets — individuals, social committees, associations — with travellers who are looking for something other than a hotel room: a hammock, a river, silence.
+
+## Why Karbé exists
+
+In French Guiana, hundreds of carbets sleep six months a year. Meanwhile, travellers look for adventure and local social committees struggle to offer affordable stays to their members. Karbé bridges those two worlds — without taking a cut along the way.
+
+## Our model
+
+Stay payments transit Stripe and **are paid in full to the owner**: 0 % commission. Karbé is funded by an annual subscription paid by hosts who want to list their carbet — not by taking a share of what you pay.
+
+## The team
+
+Karbé is run by a French Guianese digital association. We are based in Cayenne and speak French, Creole, English and Portuguese.
+
+[Become a host](/devenir-loueur) · [For social committees](/pour-comites-entreprise) · [Contact](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "comment-ca-marche",
+ category: "general",
+ title: "How it works",
+ body: `Karbé lets you book a riverside carbet in three steps.
+
+## 1. Find the carbet that fits you
+
+On [the search page](/carbets), filter by river, dates and travellers. Each carbet is tagged by access type:
+
+- 🛣️ **Road + river** — reachable by car, ideal for an easy weekend
+- 🛶 **River expedition** — pirogue only, for those who really want to sleep far away
+
+## 2. Book and pay
+
+Booking is online, secure payment via Stripe. You receive an email confirmation with access details, embarkation point, and (if relevant) the skipper's contact.
+
+## 3. Enjoy
+
+The host hands you the karbé keys (in person or via a secure box, depending on the carbet). On arrival, the hammock is up. So is the river.
+
+## And then?
+
+Once you're back, you can leave a review — it helps future travellers and gives visibility to serious hosts.
+
+[See all available carbets](/carbets)
+`,
+ },
+ {
+ slug: "faq",
+ category: "general",
+ title: "Frequently asked questions",
+ body: `## Booking
+
+### When is my payment charged?
+
+The payment is authorised on your card at booking time and captured when the host confirms availability (or automatically within 24 h).
+
+### Does Karbé take a commission?
+
+**No.** The stay is billed at the exact price set by the host, who receives it in full (minus Stripe fees).
+
+### What if I have to cancel?
+
+Each host sets their own cancellation policy. It's shown on the carbet page before payment.
+
+## On site
+
+### How does pirogue transport work?
+
+Depending on the carbet, the host provides the skipper, refers you to a partner, or lets you arrange it yourself. The details are on each page.
+
+### Is there mobile coverage?
+
+On the upper Maroni, Oyapock and Approuague rivers: **no**. Download your offline maps before leaving.`,
+ },
+ {
+ slug: "pour-comites-entreprise",
+ category: "general",
+ title: "For social committees",
+ body: `## Your carbets sleep six months a year. Let's share them.
+
+Many social committees already own a carbet — funded by member dues, reserved as a priority for staff, but often empty between weekends. Karbé lets you open those quiet periods to public travellers, with zero commission and no extra admin.
+
+## How it works
+
+1. You list your carbet on Karbé. Free for associations and committees.
+2. You block, as a priority, the dates reserved for your members.
+3. The remaining dates are offered to public travellers.
+4. Payment is collected by Stripe and remitted directly to your account.
+
+## Benefits
+
+- **Extra revenue**: public bookings fund maintenance.
+- **0 % commission**: Karbé takes nothing on stays.
+- **No paperwork**: Stripe collects and remits, no middleman.
+- **Members keep priority**: a dual-calendar system separates committee and public slots.
+
+## Next step
+
+Contact us to discuss your specific case: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+`,
+ },
+ {
+ slug: "devenir-loueur",
+ category: "general",
+ title: "Become a host",
+ body: `Got a carbet? You can list it on Karbé in a few minutes.
+
+## What you'll need
+
+- Proof of ownership or written permission
+- Photos (interior, exterior, embarkation point)
+- Honest description: amenities, access, seasonal constraints
+- A clear **cancellation policy** travellers can see before paying
+
+## Steps
+
+1. [Create a host account](/connexion) with your email and phone.
+2. Add your carbet: photos, description, GPS, embarkation point, pirogue time (if relevant), amenities.
+3. Open your calendar: which weeks are available, what seasonal constraints apply (Oyapock low water? dry season only?).
+4. Activate the annual host subscription via Stripe.
+5. You're online. Bookings come in by email.
+
+## Payment
+
+When a traveller books, Stripe collects the stay and **pays it to you in full**. Karbé takes nothing.
+
+## Questions
+
+For edge cases (jointly owned carbet, ZAD plot, agreement with a town hall…), write to us: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr).
+
+[Create my host account](/connexion)
+`,
+ },
+ {
+ slug: "cgv",
+ category: "legal",
+ title: "Terms of service",
+ body: `*Last updated: 2026-05-31*
+
+These terms govern the use of the Karbé platform (karbe.cosmolan.fr) operated by the project's umbrella association.
+
+## 1. Object
+
+Karbé is a platform connecting owners of riverside carbets located in French Guiana with travellers wishing to rent them for tourism stays. Karbé does not own the carbets and is not a party to the rental contract.
+
+## 2. Registration
+
+Registration is free for travellers. For hosts, it is conditional upon subscribing to an annual subscription billed via Stripe.
+
+## 3. Payment and remittance
+
+Stays are billed via Stripe at booking time. The amount is **paid in full to the host** (minus Stripe fees), with no Karbé commission.
+
+## 4. Host subscription
+
+The annual subscription is billed at the start of the period and tacitly renewed unless cancelled by the user.
+
+## 5. Cancellations
+
+Each host sets their own cancellation policy, shown before payment.
+
+## 6. Liability
+
+Karbé is not liable for the state of the carbet, the pirogue transport, river conditions, or any incident during the stay. The traveller is invited to take out a suitable travel insurance.
+
+## 7. Personal data
+
+See the [Privacy Policy](/politique-de-confidentialite).
+
+## 8. Applicable law
+
+French law. Court of Cayenne (French Guiana).
+
+## 9. Contact
+
+[bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "mentions-legales",
+ category: "legal",
+ title: "Legal notice",
+ body: `*Last updated: 2026-05-31*
+
+## Publisher
+
+**Karbé** is published by the project's umbrella association (under formation), based in Cayenne (French Guiana).
+
+- Email: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+- Site: [karbe.cosmolan.fr](https://karbe.cosmolan.fr)
+
+## Hosting
+
+Servers located in mainland France, operated by the Cosmolan infrastructure.
+
+## Source code
+
+Open source: [git.cosmolan.fr/tarzzan/karbe](https://git.cosmolan.fr/tarzzan/karbe).
+
+## Intellectual property
+
+Editorial content of carbet listings belongs to the hosts who published them. The Karbé brand and proprietary visual elements remain the property of the publisher.
+
+## Reporting
+
+[bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr)
+`,
+ },
+ {
+ slug: "politique-de-confidentialite",
+ category: "legal",
+ title: "Privacy policy",
+ body: `*Last updated: 2026-05-31*
+
+Karbé respects your privacy.
+
+## 1. Data collected
+
+- **Identification**: email, last name, first name, phone
+- **Booking**: dates, carbet, amount paid
+- **Technical**: IP, browser, pages visited (fraud prevention)
+
+We **do not collect** your bank card: payments are delegated to Stripe (PCI-DSS).
+
+## 2. Purposes
+
+- Traveller-host matching
+- Subscription billing
+- Fraud prevention
+- Account / booking communication
+
+## 3. Retention
+
+- Active account: as long as it's used
+- Accounting data: 10 years
+- Technical logs: 12 months max
+
+## 4. Recipients
+
+Karbé team, Stripe, host (for your stay). **No resale.**
+
+## 5. Your rights
+
+Right of access, rectification, erasure, portability, objection.
+
+Contact: [bonjour@karbe.cosmolan.fr](mailto:bonjour@karbe.cosmolan.fr) · CNIL: [cnil.fr](https://www.cnil.fr)
+
+## 6. Cookies
+
+Technical cookies only (session, preferences). No advertising or third-party tracking cookies.`,
+ },
+];
+
+export async function seedEnglishContentPages(): Promise {
+ let count = 0;
+ for (const page of PAGES_EN) {
+ await upsertContentPage({
+ slug: page.slug,
+ title: page.title,
+ body: page.body,
+ category: page.category,
+ lang: "en",
+ published: true,
+ });
+ count += 1;
+ }
+ return count;
+}