Merge pull request 'feat: pages contenu bilingues' (#36) from feat/i18n-content-pages into main

This commit is contained in:
tarzzan 2026-05-31 11:45:49 +00:00
commit df9eb5fcbd
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;
}