From 87c3e7a581081507acde09e46df5dd4f5d26ecb5 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 11:45:47 +0000 Subject: [PATCH] feat: ContentPage bilingue (PK composite slug+lang) + seed pages EN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migration.sql | 8 + prisma/schema.prisma | 6 +- src/app/a-propos/page.tsx | 5 +- src/app/cgv/page.tsx | 5 +- src/app/comment-ca-marche/page.tsx | 5 +- src/app/devenir-loueur/page.tsx | 5 +- src/app/faq/page.tsx | 5 +- src/app/mentions-legales/page.tsx | 5 +- src/app/politique-de-confidentialite/page.tsx | 5 +- src/app/pour-comites-entreprise/page.tsx | 5 +- src/lib/content-pages.ts | 70 +++-- src/lib/plugins/hooks.ts | 10 + src/lib/plugins/seeds/content-pages-en.ts | 282 ++++++++++++++++++ 13 files changed, 376 insertions(+), 40 deletions(-) create mode 100644 prisma/migrations/20260531220000_content_page_composite_key/migration.sql create mode 100644 src/lib/plugins/seeds/content-pages-en.ts 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; +}