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; +}