feat: ContentPage bilingue (PK composite slug+lang) + seed pages EN

Migration : ContentPage.id devient PK composite (slug, lang) au lieu de slug
seul, pour stocker une version FR et une version EN du même slug. Index sur
slug seul pour les lookups.

Schema Prisma : @@id([slug, lang]).

Helpers :
- getContentPage(slug, lang) avec fallback FR si la version dans la langue
  demandée n'existe pas
- listContentPages(category?, lang?) accepte un filtre lang
- upsertContentPage : utilise le composite key

Pages publiques (a-propos, faq, comment-ca-marche, pour-comites-entreprise,
devenir-loueur, cgv, mentions-legales, politique-de-confidentialite) :
ajoutent un appel à getLocale() et le passent à getContentPage.

Seeds :
- src/lib/plugins/seeds/content-pages-en.ts : 8 pages traduites en anglais
- hook onEnable du plugin i18n-fr-en : seed EN pages au toggle on. Désactiver
  i18n n'efface pas les EN pages (elles dorment, fallback FR reprend).

Résultat : quand l'utilisateur switche vers EN, /a-propos, /faq, /cgv, etc.
basculent en anglais. Le contenu hors-DB (composants UI) bascule déjà via les
dictionnaires de la PR i18n-fr-en initiale.
This commit is contained in:
Claude Integration 2026-05-31 11:45:47 +00:00
parent 88a937f2fd
commit 87c3e7a581
13 changed files with 376 additions and 40 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ import {
deactivatePirogueProviders,
seedPirogueProviders,
} from "./seeds/pirogue-providers-default";
import { seedEnglishContentPages } from "./seeds/content-pages-en";
export const pluginHooks: Record<string, PluginHookSet | undefined> = {
"demo-carbets-seed": {
@ -83,4 +84,13 @@ export const pluginHooks: Record<string, PluginHookSet | undefined> = {
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`);
},
},
};

View file

@ -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<number> {
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;
}