From 87c3e7a581081507acde09e46df5dd4f5d26ecb5 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 11:45:47 +0000 Subject: [PATCH 01/58] 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; +} From 8196a1a3f99f4f0477f639331c603b487e6f8843 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 11:48:14 +0000 Subject: [PATCH 02/58] =?UTF-8?q?chore(admin):=20adapter=20findUnique/upda?= =?UTF-8?q?te=20=C3=A0=20la=20PK=20composite=20(slug,=20lang)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin édite la version FR par défaut. Édition multi-langues = future feature. --- src/app/admin/content-pages/[slug]/page.tsx | 5 ++++- src/app/api/admin/content-pages/[slug]/route.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/admin/content-pages/[slug]/page.tsx b/src/app/admin/content-pages/[slug]/page.tsx index 00b7fe0..d9e6b25 100644 --- a/src/app/admin/content-pages/[slug]/page.tsx +++ b/src/app/admin/content-pages/[slug]/page.tsx @@ -14,7 +14,10 @@ export default async function EditContentPage({ params }: PageProps) { await requireRole([UserRole.ADMIN]); const { slug } = await params; // Pas getContentPage : il filtre published=true. Ici on veut tout voir. - const row = await prisma.contentPage.findUnique({ where: { slug } }); + // Admin édite la version FR par défaut. (Édition EN = future feature.) + const row = await prisma.contentPage.findUnique({ + where: { slug_lang: { slug, lang: "fr" } }, + }); if (!row) notFound(); // Re-construction du type minimal attendu par le formulaire. const page = { diff --git a/src/app/api/admin/content-pages/[slug]/route.ts b/src/app/api/admin/content-pages/[slug]/route.ts index fc0047e..c2db7dd 100644 --- a/src/app/api/admin/content-pages/[slug]/route.ts +++ b/src/app/api/admin/content-pages/[slug]/route.ts @@ -19,10 +19,13 @@ export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string if (!parsed.success) { return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); } - const existing = await prisma.contentPage.findUnique({ where: { slug } }); + // L'admin édite la version FR par défaut (édition multi-langues à venir). + const existing = await prisma.contentPage.findUnique({ + where: { slug_lang: { slug, lang: "fr" } }, + }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); const updated = await prisma.contentPage.update({ - where: { slug }, + where: { slug_lang: { slug, lang: "fr" } }, data: { ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), ...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}), From c69c355f903966ec85a927a7f084b1babf2ebdc1 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 12:15:07 +0000 Subject: [PATCH 03/58] feat(plugin): theme-aquarelle + hero variant (Phase 2.4 partie 1/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry : ajoute 2 plugins : - theme-aquarelle (carnet naturaliste XIXᵉ, mutual exclusion avec theme-guyane) - image-gallery-aquarelle-seed (14 aquarelles → MinIO + Media carbets démo) Hooks : - theme-guyane et theme-aquarelle se désactivent mutuellement au toggle ON via disableOtherTheme() CSS (globals.css) : - body[data-theme=aquarelle] : background papier teinté #faf5e9 + texture grain papier inline SVG + radial gradients ocres/canopy délavés - Surcharges automatiques des borders zinc/gray vers sépia délavé Layout : - PT_Serif (au lieu de Cormorant) en theme aquarelle, plus dense et encrée - data-theme = aquarelle prioritaire sur guyane si les deux sont enabled (défensif — le hook garantit normalement la mutual exclusion) Hero : - 2 versions dans le composant : guyane (existant, SVG CarbetRiver) et aquarelle (image MinIO 01-hero-fleuve-maroni.jpg en fond, voile crème, texte sépia, CTAs carrés sans rounded, hairlines, ornement de planche) - Branchement via getActiveTheme() - aquarelleUrl() helper qui construit l'URL MinIO publique Partie 2/2 (PR ultérieure) : upload des 14 images dans MinIO + hook image-gallery-aquarelle-seed + variantes aquarelle des autres composants (CarbetCard, ExperiencesSection, HowItWorksSection, CESection, Footer). --- src/app/globals.css | 25 ++++++++- src/app/layout.tsx | 26 ++++++++-- src/components/landing/HeroSection.tsx | 72 +++++++++++++++++++++++++- src/lib/plugins/hooks.ts | 26 ++++++++++ src/lib/plugins/registry.ts | 16 ++++++ src/lib/theme.ts | 28 ++++++++++ 6 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 src/lib/theme.ts diff --git a/src/app/globals.css b/src/app/globals.css index 83ca72f..63f3cb9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,8 +47,31 @@ body[data-theme="guyane"] { radial-gradient(ellipse at bottom, rgba(196, 100, 52, 0.04) 0%, transparent 60%); } +/* === Theme Aquarelle (plugin theme-aquarelle) === */ +/* Direction artistique « carnet naturaliste XIXᵉ ». Mutuellement exclusif + avec theme-guyane (le hook onEnable du plugin garantit qu'un seul est + actif à la fois). */ +body[data-theme="aquarelle"] { + --background: #faf5e9; /* papier crème teinté */ + --foreground: #2a2418; /* encre sépia foncée */ + font-family: var(--font-serif), Georgia, serif; + /* Texture grain de papier subtile via SVG inline (~1.5 KB, pas de fetch). */ + background-image: + radial-gradient(ellipse at 25% 15%, rgba(196, 100, 52, 0.05) 0%, transparent 50%), + radial-gradient(ellipse at 75% 85%, rgba(94, 94, 50, 0.05) 0%, transparent 50%), + url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='17'/%3E%3CfeColorMatrix values='0 0 0 0 0.65 0 0 0 0 0.55 0 0 0 0 0.40 0 0 0 0.18 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E"); + background-attachment: fixed; +} + +/* Surcharges visuelles aquarelle : hairlines sépia partout en remplacement + des borders zinc/gray du theme-guyane. */ +body[data-theme="aquarelle"] [class*="border-zinc-"], +body[data-theme="aquarelle"] [class*="border-gray-"] { + border-color: rgba(140, 61, 24, 0.25); +} + @media (prefers-color-scheme: dark) { - :root:not([data-theme="guyane"]) { + :root:not([data-theme="guyane"]):not([data-theme="aquarelle"]) { --background: #0a0a0a; --foreground: #ededed; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 54c6919..30c807f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono, Cormorant_Garamond } from "next/font/google"; +import { Geist, Geist_Mono, Cormorant_Garamond, PT_Serif } from "next/font/google"; import "./globals.css"; import { PluginProvider } from "@/lib/plugins/client"; import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server"; @@ -32,6 +32,15 @@ const cormorant = Cormorant_Garamond({ display: "swap", }); +// PT Serif : typographie display pour le theme Aquarelle (carnet naturaliste). +// Plus dense, plus encrée, parfaite pour les planches d'illustration. +const ptSerif = PT_Serif({ + variable: "--font-serif-aquarelle", + subsets: ["latin"], + weight: ["400", "700"], + display: "swap", +}); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; export const metadata: Metadata = { @@ -68,17 +77,26 @@ export default async function RootLayout({ enabledKeys = []; } - const themeGuyane = enabledKeys.includes("theme-guyane"); + // Aquarelle > Guyane si les deux activés (mutual exclusion garantie par + // le hook plugin, mais on est défensif au cas où). + const themeAquarelle = enabledKeys.includes("theme-aquarelle"); + const themeGuyane = !themeAquarelle && enabledKeys.includes("theme-guyane"); + const dataTheme = themeAquarelle ? "aquarelle" : themeGuyane ? "guyane" : undefined; + + // En thème aquarelle, on substitue la variable --font-serif par PT Serif + // (au lieu de Cormorant) pour coller à l'esthétique carnet. + const serifVariable = themeAquarelle ? ptSerif.variable : cormorant.variable; + const locale = await getLocale(); const messages = await dict(locale); return ( diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx index 162319a..49ab4db 100644 --- a/src/components/landing/HeroSection.tsx +++ b/src/components/landing/HeroSection.tsx @@ -3,13 +3,20 @@ import { CarbetRiver } from "@/components/illustrations/CarbetRiver"; import { LocaleSwitcher } from "@/components/LocaleSwitcher"; import { isPluginEnabled } from "@/lib/plugins/server"; import { t } from "@/lib/i18n/server"; +import { aquarelleUrl, getActiveTheme } from "@/lib/theme"; /** * Hero plein écran. Plugin `landing-hero`. Texte i18n via t() server. - * Affiche le LocaleSwitcher en haut à droite si le plugin i18n est activé. + * Selon le theme actif : + * - aquarelle : illustration MinIO `01-hero-fleuve-maroni` en fond, ambiance + * carnet de voyage, texte sépia sur papier teinté, ornement palmier en + * coin et bordure hairline sépia + * - guyane : SVG vectoriel CarbetRiver, palette tropicale moderne + * - none : retombe sur le SVG */ export async function HeroSection() { const i18nOn = await isPluginEnabled("i18n-fr-en"); + const theme = await getActiveTheme(); const eyebrow = await t("hero.eyebrow"); const titleLine1 = await t("hero.titleLine1"); const titleAccent = await t("hero.titleAccent"); @@ -17,6 +24,69 @@ export async function HeroSection() { const ctaDiscover = await t("hero.ctaDiscover"); const ctaPropose = await t("hero.ctaPropose"); + if (theme === "aquarelle") { + return ( +
+
+ {/* voile crème pour lisibilité texte sépia sur l'aquarelle */} +
+
+ + {i18nOn ? ( +
+ +
+ ) : null} + +
+ + ~ + {eyebrow} + ~ + + +

+ {titleLine1} +
+ {titleAccent}. +

+ +

+ {subtitle} +

+ +
+ + {ctaDiscover} + + + {ctaPropose} + +
+ +

+ — planche I, carnet d'expédition Karbé — +

+
+
+ ); + } + + // Theme guyane (default actuel) ou pas de theme return (
diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index 0bbedbd..20c7f87 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -30,6 +30,21 @@ import { seedPirogueProviders, } from "./seeds/pirogue-providers-default"; import { seedEnglishContentPages } from "./seeds/content-pages-en"; +import { prisma } from "@/lib/prisma"; + +// Mutuelle exclusion theme-guyane / theme-aquarelle : activer l'un +// désactive automatiquement l'autre. +async function disableOtherTheme(currentKey: string): Promise { + const other = currentKey === "theme-guyane" ? "theme-aquarelle" : "theme-guyane"; + const row = await prisma.plugin.findUnique({ where: { key: other } }); + if (row?.enabled) { + await prisma.plugin.update({ + where: { key: other }, + data: { enabled: false, lastDisabledAt: new Date() }, + }); + console.log(`[plugin ${currentKey}] désactive ${other} (mutual exclusion)`); + } +} export const pluginHooks: Record = { "demo-carbets-seed": { @@ -93,4 +108,15 @@ export const pluginHooks: Record = { console.log(`[plugin i18n-fr-en] seed: ${count} pages EN`); }, }, + // Themes : mutuellement exclusifs (un seul actif à la fois). + "theme-guyane": { + onEnable: async () => { + await disableOtherTheme("theme-guyane"); + }, + }, + "theme-aquarelle": { + onEnable: async () => { + await disableOtherTheme("theme-aquarelle"); + }, + }, }; diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts index 402ac92..d5ee90b 100644 --- a/src/lib/plugins/registry.ts +++ b/src/lib/plugins/registry.ts @@ -27,6 +27,14 @@ export const PLUGINS: PluginDescriptor[] = [ category: "visual", version: "0.1.0", }, + { + key: "theme-aquarelle", + name: "Thème Aquarelle (carnet naturaliste)", + description: + "Direction artistique « carnet de voyage XIXᵉ » : papier teinté crème, traits sépia fins, aquarelles ocres+verts délavés, typographie display PT Serif. Active automatiquement les illustrations aquarelle si présentes. Mutuellement exclusif avec theme-guyane.", + category: "visual", + version: "0.1.0", + }, { key: "landing-hero", name: "Hero d'accueil", @@ -51,6 +59,14 @@ export const PLUGINS: PluginDescriptor[] = [ category: "visual", version: "0.1.0", }, + { + key: "image-gallery-aquarelle-seed", + name: "Galerie aquarelles seed", + description: + "14 illustrations aquarelle (6 planches naturalistes carbets, 7 scènes carnet de voyage, 1 ornement palmier) stockées dans MinIO/karbe-medias/seed/aquarelle/. Création des Media liés aux 6 carbets démo. Désactivation : suppression des Media seedés (les fichiers MinIO restent).", + category: "visual", + version: "0.1.0", + }, { key: "demo-carbets-seed", name: "Carbets de démo", diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..bf8c755 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,28 @@ +/** + * Helpers theme — server-side. + * + * Centralise la résolution du theme actif (guyane | aquarelle | none) pour + * que chaque composant qui veut un rendu spécifique au theme appelle un seul + * helper plutôt que de checker `isPluginEnabled("theme-...")` individuellement. + */ + +import "server-only"; +import { isPluginEnabled } from "@/lib/plugins/server"; + +export type ActiveTheme = "guyane" | "aquarelle" | "none"; + +export async function getActiveTheme(): Promise { + if (await isPluginEnabled("theme-aquarelle")) return "aquarelle"; + if (await isPluginEnabled("theme-guyane")) return "guyane"; + return "none"; +} + +/** + * URL publique d'une illustration aquarelle hébergée dans MinIO. + * Les fichiers sont uploadés dans karbe-medias/seed/aquarelle/ et servis via + * media.karbe.cosmolan.fr (bucket public-download). + */ +export function aquarelleUrl(filename: string): string { + const base = process.env.S3_PUBLIC_URL?.replace(/\/+$/, "") ?? "https://media.karbe.cosmolan.fr/karbe-medias"; + return `${base}/seed/aquarelle/${filename}`; +} From 47258bf1be678cc72825589d10a76d688c6722b3 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 12:20:35 +0000 Subject: [PATCH 04/58] feat(plugin): image-gallery-aquarelle-seed hook + upload script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook onEnable du plugin image-gallery-aquarelle-seed : - Pour chaque carbet démo, crée une entrée Media qui pointe vers son aquarelle hébergée dans MinIO sous karbe-medias/seed/aquarelle/. - s3Key préfixé seed/aquarelle/ pour faciliter le détachement au disable. - Idempotent (skip si Media existe déjà). Hook onDisable : - Supprime tous les Media avec s3Key startsWith seed/aquarelle/. - Les fichiers MinIO restent (pas de coût de redéploiement). Script scripts/upload-aquarelles.sh : - Upload depuis /tmp/karbe-aquarelles/*.{jpg,png} vers le bucket karbe-medias. - Applique la policy public-download au bucket pour que media.karbe.cosmolan.fr serve les fichiers sans auth. - À exécuter une fois après génération des illustrations. --- scripts/upload-aquarelles.sh | 35 +++++++++++++ src/lib/plugins/hooks.ts | 15 ++++++ src/lib/plugins/seeds/aquarelle-media.ts | 64 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100755 scripts/upload-aquarelles.sh create mode 100644 src/lib/plugins/seeds/aquarelle-media.ts diff --git a/scripts/upload-aquarelles.sh b/scripts/upload-aquarelles.sh new file mode 100755 index 0000000..a208a38 --- /dev/null +++ b/scripts/upload-aquarelles.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Upload des illustrations aquarelles dans MinIO sous karbe-medias/seed/aquarelle/ +# + applique policy download (public-read) pour qu'elles soient servies via +# media.karbe.cosmolan.fr. +# +# Prerequis : +# - Fichiers présents dans /tmp/karbe-aquarelles/ +# - MinIO container karbe-minio en up + bucket karbe-medias existant +# - .env.production accessible pour récupérer MINIO_ROOT_USER/PASSWORD +# +# Usage : ./scripts/upload-aquarelles.sh + +set -euo pipefail + +SRC="${1:-/tmp/karbe-aquarelles}" +BUCKET="karbe-medias" +PREFIX="seed/aquarelle" + +ENV_FILE="/home/ubuntu/karbe/.env.production" +USER=$(sudo grep -oP '^MINIO_ROOT_USER=\K.*' "$ENV_FILE") +PASS=$(sudo grep -oP '^MINIO_ROOT_PASSWORD=\K.*' "$ENV_FILE") + +echo " upload depuis $SRC vers minio://$BUCKET/$PREFIX/" +docker run --rm \ + --network karbe-net \ + -v "$SRC:/data:ro" \ + --entrypoint sh \ + minio/mc:latest \ + -c " + mc alias set karbe http://karbe-minio:9000 '$USER' '$PASS' >/dev/null + mc cp /data/*.jpg /data/*.png karbe/$BUCKET/$PREFIX/ + mc anonymous set download karbe/$BUCKET || true + echo '---' + mc ls karbe/$BUCKET/$PREFIX/ | head -20 + " diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index 20c7f87..d3ff76e 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -30,6 +30,7 @@ import { seedPirogueProviders, } from "./seeds/pirogue-providers-default"; import { seedEnglishContentPages } from "./seeds/content-pages-en"; +import { detachAquarelleMedia, seedAquarelleMedia } from "./seeds/aquarelle-media"; import { prisma } from "@/lib/prisma"; // Mutuelle exclusion theme-guyane / theme-aquarelle : activer l'un @@ -119,4 +120,18 @@ export const pluginHooks: Record = { await disableOtherTheme("theme-aquarelle"); }, }, + "image-gallery-aquarelle-seed": { + onEnable: async () => { + const { attached } = await seedAquarelleMedia(); + console.log( + `[plugin image-gallery-aquarelle-seed] ${attached} Media attachés aux carbets démo`, + ); + }, + onDisable: async () => { + const count = await detachAquarelleMedia(); + console.log( + `[plugin image-gallery-aquarelle-seed] ${count} Media seedés détachés`, + ); + }, + }, }; diff --git a/src/lib/plugins/seeds/aquarelle-media.ts b/src/lib/plugins/seeds/aquarelle-media.ts new file mode 100644 index 0000000..75162b9 --- /dev/null +++ b/src/lib/plugins/seeds/aquarelle-media.ts @@ -0,0 +1,64 @@ +/** + * Seed du plugin `image-gallery-aquarelle-seed`. + * + * Crée des entrées Media qui pointent vers les illustrations aquarelle uploadées + * dans MinIO (bucket karbe-medias/seed/aquarelle/...). Une par carbet démo, + * + une « hero » et 4 « scènes » accessibles séparément via l'URL theme. + * + * Les fichiers MinIO doivent être uploadés AVANT activation du plugin + * (cf. scripts/upload-aquarelles.sh). Si les fichiers ne sont pas là, le seed + * crée quand même les Media (URLs publiques 404, mais le toggle reste réversible). + */ + +import { prisma } from "@/lib/prisma"; +import { MediaType } from "@/generated/prisma/enums"; +import { aquarelleUrl } from "@/lib/theme"; + +const CARBET_AQUARELLES: { slug: string; file: string }[] = [ + { slug: "demo-karbe-awara-maroni", file: "02-planche-carbet-awara.png" }, + { slug: "demo-karbe-wapa-comte", file: "03-planche-carbet-wapa.png" }, + { slug: "demo-karbe-maripa-approuague", file: "04-planche-carbet-maripa.png" }, + { slug: "demo-karbe-paripou-oyapock", file: "05-planche-carbet-paripou.png" }, + { slug: "demo-karbe-mahury-ce-hopital", file: "06-planche-carbet-mahury.png" }, + { slug: "demo-karbe-kourou-couleuvre", file: "07-planche-carbet-kourou.png" }, +]; + +const SEED_PREFIX = "seed/aquarelle/"; + +export async function seedAquarelleMedia(): Promise<{ attached: number }> { + let attached = 0; + for (const { slug, file } of CARBET_AQUARELLES) { + const carbet = await prisma.carbet.findUnique({ where: { slug } }); + if (!carbet) continue; + + const s3Key = `${SEED_PREFIX}${file}`; + const url = aquarelleUrl(file); + + // Existe déjà ? upsert manuel via s3Key (pas d'unique sur s3Key, on filtre). + const existing = await prisma.media.findFirst({ + where: { carbetId: carbet.id, s3Key }, + }); + if (existing) { + attached += 1; + continue; + } + await prisma.media.create({ + data: { + carbetId: carbet.id, + type: MediaType.PHOTO, + s3Key, + s3Url: url, + sortOrder: 0, + }, + }); + attached += 1; + } + return { attached }; +} + +export async function detachAquarelleMedia(): Promise { + const result = await prisma.media.deleteMany({ + where: { s3Key: { startsWith: SEED_PREFIX } }, + }); + return result.count; +} From bcb93c6b29a2d20176b859726b549cd76ae25910 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 18:21:50 +0000 Subject: [PATCH 05/58] =?UTF-8?q?feat(admin):=20shell=20admin=20+=20dashbo?= =?UTF-8?q?ard=20KPI=20+=20recherche=20=E2=8C=98K=20(Sprint=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout admin : - src/app/admin/layout.tsx : route protégée requireRole(ADMIN), sidebar + topbar + breadcrumbs, data-admin sur racine pour theme sobre indépendant du theme public - Sidebar : 12 sections groupées (Vue d'ensemble, Catalogue, Activité, Membres, Contenu, Système), highlight de la route courante - TopBar : prompt ⌘K, lien vers site public, email admin - Breadcrumbs : auto depuis pathname - CommandPalette : ⌘K / Ctrl K, navigation ↑↓ + Entrée, recherche live debounced 150ms Dashboard : - 7 KPI cards avec tone neutral/ok/warn/info (réservations semaine, confirmées 30j, revenus reversés, occupation, nouveaux users, carbets publiés, avis à modérer) - Section raccourcis fréquents Theme admin : - globals.css : [data-admin] override le background+font, neutralise les borders sépia/papier teinté du theme aquarelle, garantit lisibilité permanente Recherche globale : - lib/admin/search.ts : query parallèle sur Carbet, User, Booking, ContentPage, PirogueProvider (5 résultats par catégorie, LIKE insensitive) - api/admin/search?q=… route handler avec requireRole KPI : - lib/admin/kpis.ts : 7 métriques live (cache 0), Promise.all, helper formatEur Pas de dépendance externe ajoutée (cmdk, shadcn) — composants custom Tailwind pour rester léger. --- src/app/admin/layout.tsx | 24 +++ src/app/admin/page.tsx | 106 +++++++++++-- src/app/api/admin/search/route.ts | 14 ++ src/app/globals.css | 17 ++ src/components/admin/Breadcrumbs.tsx | 46 ++++++ src/components/admin/CommandPalette.tsx | 177 +++++++++++++++++++++ src/components/admin/KPICard.tsx | 44 ++++++ src/components/admin/Sidebar.tsx | 198 ++++++++++++++++++++++++ src/components/admin/TopBar.tsx | 46 ++++++ src/lib/admin/kpis.ts | 101 ++++++++++++ src/lib/admin/search.ts | 109 +++++++++++++ 11 files changed, 873 insertions(+), 9 deletions(-) create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/api/admin/search/route.ts create mode 100644 src/components/admin/Breadcrumbs.tsx create mode 100644 src/components/admin/CommandPalette.tsx create mode 100644 src/components/admin/KPICard.tsx create mode 100644 src/components/admin/Sidebar.tsx create mode 100644 src/components/admin/TopBar.tsx create mode 100644 src/lib/admin/kpis.ts create mode 100644 src/lib/admin/search.ts diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..e853a28 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { requireRole } from "@/lib/authorization"; +import { UserRole } from "@/generated/prisma/enums"; +import { Sidebar } from "@/components/admin/Sidebar"; +import { TopBar } from "@/components/admin/TopBar"; +import { Breadcrumbs } from "@/components/admin/Breadcrumbs"; +import { CommandPalette } from "@/components/admin/CommandPalette"; + +export const dynamic = "force-dynamic"; + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const session = await requireRole([UserRole.ADMIN]); + return ( +
+ +
+ + +
{children}
+
+ +
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 731159d..3249e89 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,14 +1,102 @@ -import { requireRole } from "@/lib/authorization"; +import { formatEur, getAdminKpis } from "@/lib/admin/kpis"; +import { KPICard } from "@/components/admin/KPICard"; -export default async function AdminPage() { - const session = await requireRole(["ADMIN"]); +export const dynamic = "force-dynamic"; + +export default async function AdminDashboard() { + const kpis = await getAdminKpis(); return ( -
-

Espace administrateur

-

- Accès autorisé pour {session.user.email} ({session.user.role}). -

-
+
+
+

Tableau de bord

+

+ Vue d'ensemble de l'activité Karbé. Données live (cache 0). +

+
+ +
+ + + + 50 ? "ok" : "neutral"} + /> + + + 5 ? "warn" : "neutral"} + /> +
+ +
+

+ Raccourcis fréquents +

+ +
+
); } diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts new file mode 100644 index 0000000..55f6523 --- /dev/null +++ b/src/app/api/admin/search/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { requireRole } from "@/lib/authorization"; +import { UserRole } from "@/generated/prisma/enums"; +import { adminSearch } from "@/lib/admin/search"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + await requireRole([UserRole.ADMIN]); + const url = new URL(req.url); + const q = url.searchParams.get("q") ?? ""; + const hits = await adminSearch(q); + return NextResponse.json({ hits }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 63f3cb9..77cad1f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -70,6 +70,23 @@ body[data-theme="aquarelle"] [class*="border-gray-"] { border-color: rgba(140, 61, 24, 0.25); } +/* === Theme Admin (route /admin/...) === */ +/* Indépendant des themes publics. Sobre, gris/blanc, accent ocre Karbé, + typographie sans-serif neutre. Pas de texture grain. Lisible en + permanence peu importe le toggle Aquarelle/Guyane côté site public. */ +[data-admin] { + --background: #fafafa; + --foreground: #18181b; + font-family: var(--font-geist-sans), system-ui, sans-serif; + background-image: none !important; +} +[data-admin] [class*="border-zinc-"], +[data-admin] [class*="border-gray-"] { + /* Restaure des borders neutres dans l'admin si theme aquarelle est actif + côté body (qui les surcharge en sépia). */ + border-color: #e4e4e7; +} + @media (prefers-color-scheme: dark) { :root:not([data-theme="guyane"]):not([data-theme="aquarelle"]) { --background: #0a0a0a; diff --git a/src/components/admin/Breadcrumbs.tsx b/src/components/admin/Breadcrumbs.tsx new file mode 100644 index 0000000..1206bf7 --- /dev/null +++ b/src/components/admin/Breadcrumbs.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const LABELS: Record = { + admin: "Admin", + carbets: "Carbets", + bookings: "Réservations", + reviews: "Avis", + users: "Utilisateurs", + organizations: "Organisations", + "pirogue-providers": "Prestataires", + media: "Médias", + "content-pages": "Pages", + plugins: "Plugins", + settings: "Paramètres", + audit: "Audit log", +}; + +export function Breadcrumbs() { + const pathname = usePathname(); + if (!pathname.startsWith("/admin")) return null; + const parts = pathname.split("/").filter(Boolean); + // skip if just /admin + if (parts.length <= 1) return null; + return ( + + ); +} diff --git a/src/components/admin/CommandPalette.tsx b/src/components/admin/CommandPalette.tsx new file mode 100644 index 0000000..16f82dd --- /dev/null +++ b/src/components/admin/CommandPalette.tsx @@ -0,0 +1,177 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { SearchHit } from "@/lib/admin/search"; + +const TYPE_LABEL: Record = { + carbet: "Carbet", + user: "Utilisateur", + booking: "Réservation", + page: "Page", + provider: "Prestataire", +}; +const TYPE_ACCENT: Record = { + carbet: "bg-emerald-100 text-emerald-800", + user: "bg-sky-100 text-sky-800", + booking: "bg-amber-100 text-amber-800", + page: "bg-violet-100 text-violet-800", + provider: "bg-rose-100 text-rose-800", +}; + +/** + * Palette ⌘K minimaliste, sans dépendance externe. Server search via + * /api/admin/search?q=…, navigation au clavier (↑/↓/Enter/Esc). + */ +export function CommandPalette() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + const [selected, setSelected] = useState(0); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + const abortRef = useRef(null); + + // Ouvre la palette sur ⌘K / Ctrl+K. Esc ferme. + useEffect(() => { + function onKey(e: KeyboardEvent) { + const cmd = e.metaKey || e.ctrlKey; + if (cmd && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((v) => !v); + } else if (e.key === "Escape") { + setOpen(false); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + useEffect(() => { + if (open) { + setQuery(""); + setHits([]); + setSelected(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + const runSearch = useCallback(async (q: string) => { + if (q.trim().length < 2) { + setHits([]); + return; + } + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + setLoading(true); + try { + const r = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { signal: ac.signal }); + if (r.ok) { + const j = await r.json(); + setHits(j.hits ?? []); + setSelected(0); + } + } catch { + // aborted ou erreur silencieuse + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const id = setTimeout(() => runSearch(query), 150); + return () => clearTimeout(id); + }, [query, runSearch]); + + function onListKey(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelected((s) => Math.min(s + 1, hits.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelected((s) => Math.max(s - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const hit = hits[selected]; + if (hit) { + setOpen(false); + router.push(hit.href); + } + } + } + + if (!open) return null; + + return ( +
setOpen(false)} + > +
e.stopPropagation()} + > +
+ + + + + setQuery(e.target.value)} + onKeyDown={onListKey} + className="flex-1 bg-transparent text-sm text-zinc-900 placeholder-zinc-400 focus:outline-none" + /> + + ESC + +
+ +
+ {loading ? ( +
+ ) : query.length >= 2 && hits.length === 0 ? ( +
Aucun résultat.
+ ) : hits.length === 0 ? ( +
+ Tape au moins 2 caractères. Navigation : ↑ ↓ / Entrée. +
+ ) : ( +
    + {hits.map((h, i) => ( +
  • + setOpen(false)} + onMouseEnter={() => setSelected(i)} + className={`flex items-center justify-between gap-3 px-3 py-2 text-sm ${ + i === selected ? "bg-zinc-100" : "hover:bg-zinc-50" + }`} + > + + + {TYPE_LABEL[h.type]} + + {h.title} + + {h.subtitle ? ( + {h.subtitle} + ) : null} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/admin/KPICard.tsx b/src/components/admin/KPICard.tsx new file mode 100644 index 0000000..0b83f29 --- /dev/null +++ b/src/components/admin/KPICard.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +type Tone = "neutral" | "ok" | "warn" | "info"; + +const toneStyles: Record = { + neutral: "border-zinc-200 bg-white", + ok: "border-emerald-200 bg-emerald-50", + warn: "border-amber-200 bg-amber-50", + info: "border-sky-200 bg-sky-50", +}; + +const toneText: Record = { + neutral: "text-zinc-900", + ok: "text-emerald-900", + warn: "text-amber-900", + info: "text-sky-900", +}; + +export function KPICard({ + label, + value, + hint, + tone = "neutral", + icon, +}: { + label: string; + value: string | number; + hint?: string; + tone?: Tone; + icon?: ReactNode; +}) { + return ( +
+
+ {label} + {icon ? {icon} : null} +
+
+ {value} +
+ {hint ?
{hint}
: null} +
+ ); +} diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx new file mode 100644 index 0000000..05e9695 --- /dev/null +++ b/src/components/admin/Sidebar.tsx @@ -0,0 +1,198 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +type NavItem = { + href: string; + label: string; + icon: ReactNode; + badge?: number; +}; + +type NavGroup = { + label: string; + items: NavItem[]; +}; + +const ICONS = { + dashboard: ( + + + + + + + ), + carbets: ( + + + + + ), + bookings: ( + + + + + + + ), + users: ( + + + + + + + ), + organizations: ( + + + + + + + ), + pirogue: ( + + + + + + + ), + reviews: ( + + + + ), + media: ( + + + + + + ), + pages: ( + + + + + + + ), + plugins: ( + + + + + + + ), + settings: ( + + + + + ), + audit: ( + + + + + ), +}; + +const GROUPS: NavGroup[] = [ + { + label: "Vue d'ensemble", + items: [{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard }], + }, + { + label: "Catalogue", + items: [ + { href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets }, + { href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue }, + { href: "/admin/media", label: "Médias", icon: ICONS.media }, + ], + }, + { + label: "Activité", + items: [ + { href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings }, + { href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews }, + ], + }, + { + label: "Membres", + items: [ + { href: "/admin/users", label: "Utilisateurs", icon: ICONS.users }, + { href: "/admin/organizations", label: "Organisations CE", icon: ICONS.organizations }, + ], + }, + { + label: "Contenu", + items: [{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages }], + }, + { + label: "Système", + items: [ + { href: "/admin/plugins", label: "Plugins", icon: ICONS.plugins }, + { href: "/admin/settings", label: "Paramètres", icon: ICONS.settings }, + { href: "/admin/audit", label: "Audit log", icon: ICONS.audit }, + ], + }, +]; + +export function Sidebar() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/admin/TopBar.tsx b/src/components/admin/TopBar.tsx new file mode 100644 index 0000000..e06f7c0 --- /dev/null +++ b/src/components/admin/TopBar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function TopBar({ userEmail }: { userEmail: string }) { + const [isMac, setIsMac] = useState(false); + useEffect(() => { + setIsMac(navigator.userAgent.includes("Mac")); + }, []); + + return ( +
+
+ Cmd K pour rechercher +
+
+ + + ↗ Voir le site + + {userEmail} +
+
+ ); +} diff --git a/src/lib/admin/kpis.ts b/src/lib/admin/kpis.ts new file mode 100644 index 0000000..6e593d8 --- /dev/null +++ b/src/lib/admin/kpis.ts @@ -0,0 +1,101 @@ +/** + * KPIs du dashboard admin Karbé. + * Toutes les queries sont scoppées à la company (mono-tenant pour l'instant) + * et calculent des chiffres simples mais utiles : activité récente, état du + * catalogue, modération à faire. + */ + +import "server-only"; +import { prisma } from "@/lib/prisma"; +import { BookingStatus, CarbetStatus, PaymentStatus } from "@/generated/prisma/enums"; + +export type AdminKpis = { + bookingsThisWeek: number; + bookingsConfirmed30d: number; + revenue30dCents: number; + occupancyPct: number; // 0..100 + newUsers30d: number; + publishedCarbets: number; + reviewsToModerate: number; +}; + +function startOfWeek(d = new Date()): Date { + const x = new Date(d); + const day = (x.getDay() + 6) % 7; // 0 = lundi + x.setHours(0, 0, 0, 0); + x.setDate(x.getDate() - day); + return x; +} + +function daysAgo(n: number): Date { + const x = new Date(); + x.setHours(0, 0, 0, 0); + x.setDate(x.getDate() - n); + return x; +} + +export async function getAdminKpis(): Promise { + const weekStart = startOfWeek(); + const monthStart = daysAgo(30); + + const [ + bookingsThisWeek, + bookingsConfirmed30dList, + newUsers30d, + publishedCarbets, + reviewsToModerate, + ] = await Promise.all([ + prisma.booking.count({ + where: { startDate: { gte: weekStart } }, + }), + prisma.booking.findMany({ + where: { + status: BookingStatus.CONFIRMED, + paymentStatus: PaymentStatus.SUCCEEDED, + startDate: { gte: monthStart }, + }, + select: { amount: true, startDate: true, endDate: true }, + }), + prisma.user.count({ + where: { createdAt: { gte: monthStart } }, + }), + prisma.carbet.count({ + where: { status: CarbetStatus.PUBLISHED }, + }), + prisma.review.count({ + where: { hostResponse: null }, + }), + ]); + + const revenue30dCents = bookingsConfirmed30dList.reduce( + (acc, b) => acc + Math.round(Number(b.amount) * 100), + 0, + ); + + // Occupation = total bookings * jours moyens / (publishedCarbets * 30) + // Approximation simple, on raffine en sprint 3. + const bookedNights = bookingsConfirmed30dList.reduce((acc, b) => { + const diffMs = b.endDate.getTime() - b.startDate.getTime(); + return acc + Math.max(0, Math.round(diffMs / (1000 * 60 * 60 * 24))); + }, 0); + const occupancyDen = publishedCarbets * 30; + const occupancyPct = occupancyDen > 0 ? Math.min(100, Math.round((bookedNights * 100) / occupancyDen)) : 0; + + return { + bookingsThisWeek, + bookingsConfirmed30d: bookingsConfirmed30dList.length, + revenue30dCents, + occupancyPct, + newUsers30d, + publishedCarbets, + reviewsToModerate, + }; +} + +export function formatEur(cents: number): string { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + }).format(cents / 100); +} diff --git a/src/lib/admin/search.ts b/src/lib/admin/search.ts new file mode 100644 index 0000000..12b85cb --- /dev/null +++ b/src/lib/admin/search.ts @@ -0,0 +1,109 @@ +/** + * Recherche globale ⌘K — server function. + * + * Recherche transversale sur carbets / utilisateurs / réservations / + * pages éditoriales / prestataires pirogue. Renvoie au max 5 résultats + * par catégorie pour garder la palette lisible. + */ + +import "server-only"; +import { prisma } from "@/lib/prisma"; + +export type SearchHit = { + type: "carbet" | "user" | "booking" | "page" | "provider"; + id: string; + title: string; + subtitle?: string; + href: string; +}; + +export async function adminSearch(query: string): Promise { + const q = query.trim(); + if (q.length < 2) return []; + const ci = { contains: q, mode: "insensitive" as const }; + + const [carbets, users, bookings, pages, providers] = await Promise.all([ + prisma.carbet.findMany({ + where: { + OR: [{ slug: ci }, { title: ci }, { river: ci }], + }, + take: 5, + select: { id: true, slug: true, title: true, river: true, status: true }, + }), + prisma.user.findMany({ + where: { + OR: [{ email: ci }, { firstName: ci }, { lastName: ci }], + }, + take: 5, + select: { id: true, email: true, firstName: true, lastName: true, role: true }, + }), + prisma.booking.findMany({ + where: { id: ci }, + take: 5, + select: { id: true, status: true, startDate: true, endDate: true }, + }), + prisma.contentPage.findMany({ + where: { + OR: [{ slug: ci }, { title: ci }], + lang: "fr", + }, + take: 5, + select: { slug: true, title: true, category: true, lang: true }, + }), + prisma.pirogueProvider.findMany({ + where: { OR: [{ name: ci }] }, + take: 5, + select: { id: true, name: true, rivers: true }, + }), + ]); + + const hits: SearchHit[] = []; + + for (const c of carbets) { + hits.push({ + type: "carbet", + id: c.id, + title: c.title, + subtitle: `${c.river} · ${c.status}`, + href: `/admin/carbets/${c.id}`, + }); + } + for (const u of users) { + hits.push({ + type: "user", + id: u.id, + title: `${u.firstName} ${u.lastName}`.trim() || u.email, + subtitle: `${u.email} · ${u.role}`, + href: `/admin/users/${u.id}`, + }); + } + for (const b of bookings) { + hits.push({ + type: "booking", + id: b.id, + title: `Réservation ${b.id.slice(0, 8)}`, + subtitle: `${b.status} · ${b.startDate.toISOString().slice(0, 10)} → ${b.endDate.toISOString().slice(0, 10)}`, + href: `/admin/bookings/${b.id}`, + }); + } + for (const p of pages) { + hits.push({ + type: "page", + id: p.slug, + title: p.title, + subtitle: `/${p.slug} · ${p.category} · ${p.lang}`, + href: `/admin/content-pages/${encodeURIComponent(p.slug)}`, + }); + } + for (const p of providers) { + hits.push({ + type: "provider", + id: p.id, + title: p.name, + subtitle: p.rivers.join(" · "), + href: `/admin/pirogue-providers/${p.id}`, + }); + } + + return hits; +} From 9aa07710012a97e6a2b69f2f68d82c8a79e9c013 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 19:51:33 +0000 Subject: [PATCH 06/58] =?UTF-8?q?feat(admin):=20CRUD=20complet=20carbets?= =?UTF-8?q?=20+=20gestion=20m=C3=A9dias=20(Sprint=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server actions (src/app/admin/carbets/actions.ts) avec validation Zod : - createCarbetAction → INSERT + audit + redirect /admin/carbets/[id] - updateCarbetAction → UPDATE + revalidate page publique - updateCarbetStatusAction → DRAFT/PUBLISHED/ARCHIVED - deleteCarbetAction → soft archive (bookings/reviews FK Restrict) - addMediaAction(carbetId, fd) → INSERT Media + sortOrder - removeMediaAction, reorderMediaAction (transactionnel up/down) Helpers (src/lib/admin/carbets.ts) : - listCarbetsAdmin avec filtres (q/river/status/accessType) - listDistinctRivers, listOwners, listPirogueProviders - getCarbetForEdit (include owner, provider, media, _count bookings/reviews) - Options enum pour les selects (ACCESS_TYPE, TRANSPORT_MODE, STATUS) Pages : - /admin/carbets : liste tableau dense avec recherche/filtres GET, status badge, liens vers édition, count médias/résas - /admin/carbets/new : page création avec CarbetForm - /admin/carbets/[id] : header titre+badge+actions, MediaManager, CarbetForm d'édition. Lien public si PUBLISHED. Composants admin réutilisables : - StatusBadge (DRAFT/PUBLISHED/ARCHIVED + statuts Booking) - FormField + inputCls/selectCls/textareaCls - CarbetForm (client, 5 sections : identité, localisation, accès, séjour, publication) avec useTransition + erreur + succès inline - MediaManager (client, liste + reorder ↑↓ + suppression + ajout par URL) - StatusActions (client, publier/dépublier/archiver/réactiver avec confirm) API : - GET /api/admin/carbets/[id]/media pour refresh client après mutation Audit léger en log console (JSON structuré) — Sprint 5 ajoutera la table. --- .../carbets/[id]/_components/MediaManager.tsx | 142 +++++++++ .../[id]/_components/StatusActions.tsx | 93 ++++++ src/app/admin/carbets/[id]/page.tsx | 103 +++++++ .../admin/carbets/_components/CarbetForm.tsx | 269 ++++++++++++++++++ src/app/admin/carbets/actions.ts | 219 ++++++++++++++ src/app/admin/carbets/new/page.tsx | 20 ++ src/app/admin/carbets/page.tsx | 146 ++++++++++ src/app/api/admin/carbets/[id]/media/route.ts | 17 ++ src/components/admin/FormField.tsx | 32 +++ src/components/admin/StatusBadge.tsx | 31 ++ src/lib/admin/carbets.ts | 130 +++++++++ 11 files changed, 1202 insertions(+) create mode 100644 src/app/admin/carbets/[id]/_components/MediaManager.tsx create mode 100644 src/app/admin/carbets/[id]/_components/StatusActions.tsx create mode 100644 src/app/admin/carbets/[id]/page.tsx create mode 100644 src/app/admin/carbets/_components/CarbetForm.tsx create mode 100644 src/app/admin/carbets/actions.ts create mode 100644 src/app/admin/carbets/new/page.tsx create mode 100644 src/app/admin/carbets/page.tsx create mode 100644 src/app/api/admin/carbets/[id]/media/route.ts create mode 100644 src/components/admin/FormField.tsx create mode 100644 src/components/admin/StatusBadge.tsx create mode 100644 src/lib/admin/carbets.ts diff --git a/src/app/admin/carbets/[id]/_components/MediaManager.tsx b/src/app/admin/carbets/[id]/_components/MediaManager.tsx new file mode 100644 index 0000000..47947da --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/MediaManager.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Image from "next/image"; +import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions"; +import { FormField, inputCls, selectCls } from "@/components/admin/FormField"; + +type MediaItem = { + id: string; + type: "PHOTO" | "VIDEO"; + s3Key: string; + s3Url: string; + sortOrder: number; +}; + +export function MediaManager({ carbetId, media: initial }: { carbetId: string; media: MediaItem[] }) { + const [media, setMedia] = useState(initial); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + async function refresh() { + const r = await fetch(`/api/admin/carbets/${carbetId}/media`); + if (r.ok) setMedia(await r.json()); + } + + function addByUrl(fd: FormData) { + setError(null); + startTransition(async () => { + const res = await addMediaAction(carbetId, fd); + if (res?.ok === false) { + setError(res.error); + } else { + await refresh(); + } + }); + } + + function remove(mediaId: string) { + startTransition(async () => { + await removeMediaAction(carbetId, mediaId); + await refresh(); + }); + } + + function reorder(mediaId: string, dir: "up" | "down") { + startTransition(async () => { + await reorderMediaAction(carbetId, mediaId, dir); + await refresh(); + }); + } + + return ( +
+

Médias ({media.length})

+ + {media.length === 0 ? ( +

+ Aucun média. Ajoute une URL ci-dessous (MinIO, CDN externe, …). +

+ ) : ( +
    + {media.map((m, i) => ( +
  • + #{i + 1} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
    +
    {m.s3Url}
    +
    + {m.type} · {m.s3Key} +
    +
    +
    + + + +
    +
  • + ))} +
+ )} + +
+

Ajouter un média par URL

+
+ + + + + + +
+ + {error ?
{error}
: null} +
+ +
+
+
+ ); +} diff --git a/src/app/admin/carbets/[id]/_components/StatusActions.tsx b/src/app/admin/carbets/[id]/_components/StatusActions.tsx new file mode 100644 index 0000000..7d585ef --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/StatusActions.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { CarbetStatus } from "@/generated/prisma/enums"; +import { deleteCarbetAction, updateCarbetStatusAction } from "../../actions"; + +type Status = (typeof CarbetStatus)[keyof typeof CarbetStatus]; + +export function StatusActions({ id, current }: { id: string; current: Status }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirmArchive, setConfirmArchive] = useState(false); + + function setStatus(next: Status) { + startTransition(async () => { + await updateCarbetStatusAction(id, next); + router.refresh(); + }); + } + + function archive() { + startTransition(async () => { + await deleteCarbetAction(id); + }); + } + + return ( +
+ {current === CarbetStatus.DRAFT ? ( + + ) : null} + {current === CarbetStatus.PUBLISHED ? ( + + ) : null} + {current !== CarbetStatus.ARCHIVED ? ( + confirmArchive ? ( +
+ Sûr ? + + +
+ ) : ( + + ) + ) : ( + + )} +
+ ); +} diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx new file mode 100644 index 0000000..5e8f635 --- /dev/null +++ b/src/app/admin/carbets/[id]/page.tsx @@ -0,0 +1,103 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { + getCarbetForEdit, + listOwners, + listPirogueProviders, +} from "@/lib/admin/carbets"; +import { CarbetForm } from "../_components/CarbetForm"; +import { StatusBadge } from "@/components/admin/StatusBadge"; +import { MediaManager } from "./_components/MediaManager"; +import { StatusActions } from "./_components/StatusActions"; +import { updateCarbetAction } from "../actions"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ id: string }> }; + +export default async function EditCarbetPage({ params }: PageProps) { + const { id } = await params; + const [carbet, owners, providers] = await Promise.all([ + getCarbetForEdit(id), + listOwners(), + listPirogueProviders(), + ]); + if (!carbet) notFound(); + + const updateThis = async (fd: FormData) => { + "use server"; + return await updateCarbetAction(id, fd); + }; + + return ( +
+
+
+ + ← Tous les carbets + +

+ {carbet.title} + +

+

+ /{carbet.slug} · {carbet._count.bookings} résa + {carbet._count.bookings > 1 ? "s" : ""} · {carbet._count.reviews} avis · + mis à jour {new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(carbet.updatedAt)} +

+
+
+ + {carbet.status === "PUBLISHED" ? ( + + ↗ Voir la fiche publique + + ) : null} +
+
+ + ({ + id: m.id, + type: m.type, + s3Key: m.s3Key, + s3Url: m.s3Url, + sortOrder: m.sortOrder, + }))} + /> + + +
+ ); +} diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx new file mode 100644 index 0000000..11d8460 --- /dev/null +++ b/src/app/admin/carbets/_components/CarbetForm.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField"; +import { + ACCESS_TYPE_OPTIONS, + STATUS_OPTIONS, + TRANSPORT_MODE_OPTIONS, +} from "@/lib/admin/carbets"; + +export type CarbetFormInitial = { + ownerId?: string; + title?: string; + slug?: string; + description?: string; + river?: string; + embarkPoint?: string; + latitude?: number | string; + longitude?: number | string; + capacity?: number; + accessType?: string; + roadAccessNote?: string | null; + pirogueDurationMin?: number | null; + minStayNights?: number | null; + maxStayNights?: number | null; + minCapacity?: number | null; + transportMode?: string | null; + pirogueProviderId?: string | null; + status?: string; +}; + +type Props = { + initial?: CarbetFormInitial; + owners: { id: string; firstName: string; lastName: string; email: string }[]; + providers: { id: string; name: string; rivers: string[] }[]; + action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>; + submitLabel?: string; +}; + +export function CarbetForm({ initial = {}, owners, providers, action, submitLabel = "Enregistrer" }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function onSubmit(formData: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await action(formData); + if (res && res.ok === false) { + setError(res.error); + } else if (res && res.ok === true) { + setSuccess("Carbet enregistré."); + } + }); + } + + return ( +
+
+ {/* Identité */} +
+

Identité

+
+ + + + + + + + + + +