From e433ebc4398369e2c063b5d9a85441132aa2afa2 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:30:12 +0000 Subject: [PATCH 01/68] chore(plugins): cast config en Prisma.InputJsonValue Le type Record ne satisfait pas le narrowing JSON Prisma. Cast explicite pour faire passer le build TS. --- src/lib/plugins/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/plugins/server.ts b/src/lib/plugins/server.ts index 422a0b7..d8e0304 100644 --- a/src/lib/plugins/server.ts +++ b/src/lib/plugins/server.ts @@ -9,6 +9,7 @@ */ import "server-only"; +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { PLUGINS, type PluginDescriptor } from "./registry"; import { pluginHooks } from "./hooks"; @@ -123,7 +124,10 @@ export async function togglePlugin(key: string, enabled: boolean): Promise): Promise { - await prisma.plugin.update({ where: { key }, data: { config } }); + await prisma.plugin.update({ + where: { key }, + data: { config: config as Prisma.InputJsonValue }, + }); invalidatePluginCache(); return await getPluginState(key); } From b1c2877e43962c85641d862e48811b630d130a8f Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:32:07 +0000 Subject: [PATCH 02/68] chore(sitemap): force dynamic + try/catch DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Évite que le build échoue quand la DB n'est pas joignable au prerender. --- src/app/sitemap.ts | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 78d6edf..ab2d8e0 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -3,6 +3,11 @@ import type { MetadataRoute } from "next"; import { prisma } from "@/lib/prisma"; import { CarbetStatus } from "@/generated/prisma/enums"; +// La sitemap interroge la DB → on force le rendu dynamique pour éviter le +// prerender au build (qui n'a pas accès à la DB de prod). +export const dynamic = "force-dynamic"; +export const revalidate = 0; + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; function abs(path: string): string { @@ -22,17 +27,23 @@ export default async function sitemap(): Promise { }, ]; - const carbets = await prisma.carbet.findMany({ - where: { status: CarbetStatus.PUBLISHED }, - select: { slug: true, updatedAt: true }, - }); - - const carbetRoutes: MetadataRoute.Sitemap = carbets.map((carbet) => ({ - url: abs(`/carbets/${carbet.slug}`), - lastModified: carbet.updatedAt, - changeFrequency: "weekly", - priority: 0.7, - })); + let carbetRoutes: MetadataRoute.Sitemap = []; + try { + const carbets = await prisma.carbet.findMany({ + where: { status: CarbetStatus.PUBLISHED }, + select: { slug: true, updatedAt: true }, + }); + carbetRoutes = carbets.map((carbet) => ({ + url: abs(`/carbets/${carbet.slug}`), + lastModified: carbet.updatedAt, + changeFrequency: "weekly", + priority: 0.7, + })); + } catch { + // DB indisponible (build statique, par ex.) — on retombe sur les routes + // statiques seules, plutôt que de faire échouer la génération. + carbetRoutes = []; + } return [...staticRoutes, ...carbetRoutes]; } From 049d0bb423edd5510664fdd6e05aa00c66e9bfa4 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:36:42 +0000 Subject: [PATCH 03/68] =?UTF-8?q?chore(layout):=20force-dynamic=20pour=20r?= =?UTF-8?q?efl=C3=A9ter=20l'=C3=A9tat=20des=20plugins=20en=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans ça, le layout est rendu statiquement au build et ne re-fetch jamais l'état des plugins, donc les toggles depuis /admin/plugins ne prennent jamais effet sur la home jusqu'à un nouveau build. --- src/app/layout.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c03076a..d06bd3f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,12 @@ import "./globals.css"; import { PluginProvider } from "@/lib/plugins/client"; import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server"; +// Le layout interroge la DB Plugin à chaque request → rendu dynamique forcé. +// Sans ça, le layout (et donc data-theme + enabledKeys passés au client) est +// statiquement rendu au build et ne reflète plus l'état actuel des toggles. +export const dynamic = "force-dynamic"; +export const revalidate = 0; + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], From 5e59202505916e0fde093f25a519032c40e157cb Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 02:56:25 +0000 Subject: [PATCH 04/68] feat(plugins): access-type + demo-carbets-seed (Phase 3.1 + 2.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin access-type : - Migration : enum AccessType (ROAD_AND_RIVER, RIVER_ONLY), champ accessType sur Carbet avec default ROAD_AND_RIVER, roadAccessNote optionnel, pirogueDurationMin rendu nullable + index sur accessType - Schema Prisma mis à jour - Composant client, gated par le plugin - Carbet card et fiche enrichies : badge + texte adapté (Pirogue vs Route+pirogue vs Route directe), section Accès enrichie avec roadAccessNote - formatPirogueDuration accepte null Plugin demo-carbets-seed : - Hook onEnable : 3 propriétaires demo (Yann/Émilie/CE Hôpital) + 6 carbets variés (Maroni, Approuague, Comté, Oyapock, Mahury, Kourou) avec mix 3 RIVER_ONLY + 3 ROAD_AND_RIVER, GPS plausibles, descriptions naturelles - Hook onDisable : archive (status=ARCHIVED) les carbets demo via slug prefix - Toutes les fixtures idempotentes (upsert via slug + email) --- .../migration.sql | 13 ++ prisma/schema.prisma | 8 +- src/app/carbets/[slug]/page.tsx | 43 +++- src/app/carbets/_components/carbet-card.tsx | 21 +- src/components/AccessTypeBadge.tsx | 43 ++++ src/lib/carbet-public.ts | 10 +- src/lib/carbet-search.ts | 21 +- src/lib/format.ts | 5 +- src/lib/plugins/hooks.ts | 21 +- src/lib/plugins/seeds/demo-carbets.ts | 221 ++++++++++++++++++ 10 files changed, 380 insertions(+), 26 deletions(-) create mode 100644 prisma/migrations/20260531000000_add_access_type/migration.sql create mode 100644 src/components/AccessTypeBadge.tsx create mode 100644 src/lib/plugins/seeds/demo-carbets.ts diff --git a/prisma/migrations/20260531000000_add_access_type/migration.sql b/prisma/migrations/20260531000000_add_access_type/migration.sql new file mode 100644 index 0000000..e15c7db --- /dev/null +++ b/prisma/migrations/20260531000000_add_access_type/migration.sql @@ -0,0 +1,13 @@ +-- Plugin access-type : distinction route+fleuve / fleuve only + +CREATE TYPE "AccessType" AS ENUM ('ROAD_AND_RIVER', 'RIVER_ONLY'); + +ALTER TABLE "Carbet" + ADD COLUMN "accessType" "AccessType" NOT NULL DEFAULT 'ROAD_AND_RIVER', + ADD COLUMN "roadAccessNote" TEXT; + +-- La pirogue n'est obligatoire qu'en RIVER_ONLY. Pour ROAD_AND_RIVER, la valeur +-- est optionnelle (estimation pour ceux qui veulent quand même venir en pirogue). +ALTER TABLE "Carbet" ALTER COLUMN "pirogueDurationMin" DROP NOT NULL; + +CREATE INDEX "Carbet_accessType_idx" ON "Carbet" ("accessType"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c908b52..b8c9cd6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,7 +106,12 @@ model Carbet { latitude Decimal @db.Decimal(9, 6) longitude Decimal @db.Decimal(9, 6) embarkPoint String - pirogueDurationMin Int + // Pirogue : obligatoire pour RIVER_ONLY, optionnelle pour ROAD_AND_RIVER + // (estimation pour ceux qui veulent quand même venir en pirogue). + pirogueDurationMin Int? + accessType AccessType @default(ROAD_AND_RIVER) + // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). + roadAccessNote String? capacity Int status CarbetStatus @default(DRAFT) lastBookedAt DateTime? @@ -124,6 +129,7 @@ model Carbet { @@index([ownerId]) @@index([status]) @@index([river]) + @@index([accessType]) } model Amenity { diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index ae88cad..6acb95b 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -15,6 +15,7 @@ import { formatAverageRating } from "@/lib/reviews"; import { CarbetGallery } from "../_components/carbet-gallery"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; +import { AccessTypeBadge } from "@/components/AccessTypeBadge"; type PageProps = { params: Promise<{ slug: string }>; @@ -88,17 +89,23 @@ export default async function PublicCarbetPage({ params }: PageProps) {
-

- Fleuve {carbet.river} -

+
+

+ Fleuve {carbet.river} +

+ +

{carbet.title}

Accueil par {carbet.ownerFirstName} · {carbet.capacity} voyageur - {carbet.capacity > 1 ? "s" : ""} · Pirogue{" "} - {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "} - {carbet.embarkPoint} + {carbet.capacity > 1 ? "s" : ""} + {carbet.accessType === "RIVER_ONLY" + ? ` · Pirogue ${formatPirogueDuration(carbet.pirogueDurationMin)} depuis ${carbet.embarkPoint}` + : carbet.pirogueDurationMin + ? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}` + : ` · Route directe (embarquement ${carbet.embarkPoint})`}

{carbet.reviewStats.count > 0 && carbet.reviewStats.averageRating !== null ? ( @@ -157,16 +164,32 @@ export default async function PublicCarbetPage({ params }: PageProps) { Accès au carbet
+
+
Type d'accès
+
+ {carbet.accessType === "RIVER_ONLY" + ? "Expédition fleuve uniquement" + : "Route + fleuve"} +
+
+ {carbet.roadAccessNote ? ( +
+
Accès route
+
{carbet.roadAccessNote}
+
+ ) : null}
Point d'embarquement
{carbet.embarkPoint}
-
-
Trajet pirogue
-
{formatPirogueDuration(carbet.pirogueDurationMin)}
-
+ {carbet.pirogueDurationMin !== null ? ( +
+
Trajet pirogue
+
{formatPirogueDuration(carbet.pirogueDurationMin)}
+
+ ) : null}
Coordonnées GPS
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index feecf82..a757b1a 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import type { CarbetSearchResult } from "@/lib/carbet-search"; import { formatPirogueDuration, truncate } from "@/lib/format"; import { formatAverageRating } from "@/lib/reviews"; +import { AccessTypeBadge } from "@/components/AccessTypeBadge"; import { StarRating } from "./star-rating"; @@ -28,11 +29,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) { )}
-

- - {carbet.title} - -

+
+

+ + {carbet.title} + +

+ +

Fleuve {carbet.river} · {carbet.capacity} voyageur {carbet.capacity > 1 ? "s" : ""} @@ -50,8 +54,11 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) { {truncate(carbet.description, 180)}

- Pirogue {formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "} - {carbet.embarkPoint} + {carbet.accessType === "RIVER_ONLY" + ? `Pirogue ${formatPirogueDuration(carbet.pirogueDurationMin)} depuis ${carbet.embarkPoint}` + : carbet.pirogueDurationMin + ? `Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}` + : `Accessible par la route — embarquement possible à ${carbet.embarkPoint}`}

diff --git a/src/components/AccessTypeBadge.tsx b/src/components/AccessTypeBadge.tsx new file mode 100644 index 0000000..27b8d84 --- /dev/null +++ b/src/components/AccessTypeBadge.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useIsPluginEnabled } from "@/lib/plugins/client"; +import type { AccessType } from "@/generated/prisma/enums"; + +/** + * Badge route+fleuve vs fleuve only. Gated par le plugin `access-type`. + * Si le plugin est désactivé, rien n'est rendu — la fiche tombe sur le + * comportement legacy (pirogue toujours mentionnée). + */ +export function AccessTypeBadge({ + accessType, + size = "sm", +}: { + accessType: AccessType; + size?: "sm" | "md"; +}) { + const enabled = useIsPluginEnabled("access-type"); + if (!enabled) return null; + + const isExpedition = accessType === "RIVER_ONLY"; + const label = isExpedition ? "🛶 Expédition fleuve" : "🛣️ Route + fleuve"; + const styles = isExpedition + ? "bg-[var(--color-karbe-laterite-300)]/25 text-[var(--color-karbe-laterite-700)] ring-[var(--color-karbe-laterite-500)]/30" + : "bg-[var(--color-karbe-canopy-50)] text-[var(--color-karbe-canopy-700)] ring-[var(--color-karbe-canopy-500)]/30"; + const sizing = + size === "md" + ? "px-3 py-1.5 text-xs" + : "px-2 py-0.5 text-[11px]"; + + return ( + + {label} + + ); +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index afc59e0..233898c 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -2,7 +2,7 @@ import { cache } from "react"; import { prisma } from "@/lib/prisma"; import { amenityLabel } from "@/lib/amenities"; -import { CarbetStatus, MediaType } from "@/generated/prisma/enums"; +import { AccessType, CarbetStatus, MediaType } from "@/generated/prisma/enums"; import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews"; import { getCarbetReviewStats, @@ -22,7 +22,9 @@ export type PublicCarbetDetail = { description: string; river: string; embarkPoint: string; - pirogueDurationMin: number; + pirogueDurationMin: number | null; + accessType: AccessType; + roadAccessNote: string | null; capacity: number; latitude: string; longitude: string; @@ -48,6 +50,8 @@ export const getPublicCarbet = cache( river: true, embarkPoint: true, pirogueDurationMin: true, + accessType: true, + roadAccessNote: true, capacity: true, latitude: true, longitude: true, @@ -78,6 +82,8 @@ export const getPublicCarbet = cache( river: carbet.river, embarkPoint: carbet.embarkPoint, pirogueDurationMin: carbet.pirogueDurationMin, + accessType: carbet.accessType, + roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, latitude: carbet.latitude.toString(), longitude: carbet.longitude.toString(), diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index b463430..aa8b4be 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { Prisma } from "@/generated/prisma/client"; import { + AccessType, AvailabilityBlockReason, AvailabilityScope, CarbetStatus, @@ -12,6 +13,9 @@ export type CarbetSearchFilters = { startDate?: Date; endDate?: Date; capacity?: number; + // Filtre plugin access-type : si "river-only" exclu, on garde uniquement + // ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe. + accessibility?: "road-only" | "all"; }; export type RawSearchParams = { @@ -60,6 +64,11 @@ export function parseSearchFilters( } } + const accessibility = pickString(searchParams.accessibility); + if (accessibility === "road-only" || accessibility === "all") { + filters.accessibility = accessibility; + } + return filters; } @@ -69,7 +78,9 @@ export type CarbetSearchResult = { title: string; river: string; embarkPoint: string; - pirogueDurationMin: number; + pirogueDurationMin: number | null; + accessType: AccessType; + roadAccessNote: string | null; capacity: number; description: string; coverUrl: string | null; @@ -94,6 +105,10 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput { where.capacity = { gte: filters.capacity }; } + if (filters.accessibility === "road-only") { + where.accessType = AccessType.ROAD_AND_RIVER; + } + if (filters.startDate && filters.endDate) { where.availabilities = { some: { @@ -124,6 +139,8 @@ export async function searchCarbets( river: true, embarkPoint: true, pirogueDurationMin: true, + accessType: true, + roadAccessNote: true, capacity: true, description: true, media: { @@ -149,6 +166,8 @@ export async function searchCarbets( river: carbet.river, embarkPoint: carbet.embarkPoint, pirogueDurationMin: carbet.pirogueDurationMin, + accessType: carbet.accessType, + roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, description: carbet.description, coverUrl: carbet.media[0]?.s3Url ?? null, diff --git a/src/lib/format.ts b/src/lib/format.ts index 5770802..facf408 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,6 +1,7 @@ // Format a pirogue trip duration (minutes) into a human readable French label -// such as "45 min" or "1 h 20". -export function formatPirogueDuration(minutes: number): string { +// such as "45 min" or "1 h 20". Null = pas de pirogue requise (carbet routier). +export function formatPirogueDuration(minutes: number | null | undefined): string { + if (minutes === null || minutes === undefined) return "—"; if (minutes < 60) return `${minutes} min`; const hours = Math.floor(minutes / 60); const rest = minutes % 60; diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index ad20403..452c5c8 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -14,6 +14,21 @@ export interface PluginHookSet { onDisable?: PluginHook; } -// Pour l'instant, vide : les plugins métier ajouteront leurs hooks ici -// au fur et à mesure (sans toucher au runtime du système Plugin). -export const pluginHooks: Record = {}; +import { archiveDemoCarbets, seedDemoCarbets } from "./seeds/demo-carbets"; + +export const pluginHooks: Record = { + "demo-carbets-seed": { + onEnable: async () => { + const { created, existing } = await seedDemoCarbets(); + console.log( + `[plugin demo-carbets-seed] seed terminé : ${created} créés, ${existing} déjà présents`, + ); + }, + onDisable: async () => { + const archived = await archiveDemoCarbets(); + console.log( + `[plugin demo-carbets-seed] disable : ${archived} carbets démo archivés`, + ); + }, + }, +}; diff --git a/src/lib/plugins/seeds/demo-carbets.ts b/src/lib/plugins/seeds/demo-carbets.ts new file mode 100644 index 0000000..eafdbc7 --- /dev/null +++ b/src/lib/plugins/seeds/demo-carbets.ts @@ -0,0 +1,221 @@ +/** + * Seed du plugin `demo-carbets-seed`. + * + * Crée 3 propriétaires fictifs et 6 carbets démo répartis sur 4 fleuves + * (Maroni, Approuague, Comté, Oyapock) et 2 types d'accès. Les carbets démo + * sont tagués par leur slug préfixé `demo-` pour pouvoir être soft-deleted + * (`status=ARCHIVED`) au disable du plugin sans toucher aux carbets utilisateurs. + */ + +import { prisma } from "@/lib/prisma"; +import { hashPassword } from "@/lib/password"; +import { + AccessType, + CarbetStatus, + UserRole, +} from "@/generated/prisma/enums"; + +const DEMO_OWNERS = [ + { + email: "demo-yann@karbe.demo", + firstName: "Yann", + lastName: "Cabassou", + phone: "+594-694-001122", + }, + { + email: "demo-emilie@karbe.demo", + firstName: "Émilie", + lastName: "Sénégal", + phone: "+594-694-003344", + }, + { + email: "demo-ce-hopital@karbe.demo", + firstName: "CE", + lastName: "Hôpital Cayenne", + phone: "+594-594-005566", + }, +] as const; + +type DemoCarbet = { + slug: string; + title: string; + ownerIdx: 0 | 1 | 2; + river: string; + embarkPoint: string; + accessType: AccessType; + pirogueDurationMin: number | null; + roadAccessNote: string | null; + latitude: number; + longitude: number; + capacity: number; + description: string; +}; + +const DEMO_CARBETS: DemoCarbet[] = [ + { + slug: "demo-karbe-awara-maroni", + title: "Karbé Awara", + ownerIdx: 0, + river: "Maroni", + embarkPoint: "Dégrad Apatou", + accessType: AccessType.RIVER_ONLY, + pirogueDurationMin: 90, + roadAccessNote: null, + latitude: 5.2008, + longitude: -53.9519, + capacity: 6, + description: + "Au cœur du Maroni, à 1h30 de pirogue d'Apatou. Trois hamacs, une terrasse qui domine le fleuve, le silence total. Bois local, panneau solaire, eau pluviale. Le passeur fait l'aller-retour à votre demande.", + }, + { + slug: "demo-karbe-wapa-comte", + title: "Karbé Wapa", + ownerIdx: 1, + river: "Comté", + embarkPoint: "Roura, ponton municipal", + accessType: AccessType.ROAD_AND_RIVER, + pirogueDurationMin: 20, + roadAccessNote: + "30 km de piste depuis Roura, dernier kilomètre en 4×4 conseillé en saison des pluies. Parking sécurisé au ponton.", + latitude: 4.7281, + longitude: -52.3261, + capacity: 4, + description: + "Carbet familial accessible en voiture, à 30 min du centre de Roura. Idéal week-end : on arrive vendredi soir, on dort au bord du Comté, on baigne dimanche matin. Pirogue dispo pour explorer en amont.", + }, + { + slug: "demo-karbe-maripa-approuague", + title: "Karbé Maripa", + ownerIdx: 0, + river: "Approuague", + embarkPoint: "Saint-Georges, dégrad principal", + accessType: AccessType.RIVER_ONLY, + pirogueDurationMin: 180, + roadAccessNote: null, + latitude: 3.9001, + longitude: -51.8101, + capacity: 8, + description: + "Trois heures de remontée de l'Approuague, plus rien ne vient brouiller la nuit. Carbet ancien rénové, deux pièces séparées, cuisine au feu de bois. Singes hurleurs garantis au lever du soleil.", + }, + { + slug: "demo-karbe-paripou-oyapock", + title: "Karbé Paripou", + ownerIdx: 1, + river: "Oyapock", + embarkPoint: "Saint-Georges, embarcadère mairie", + accessType: AccessType.RIVER_ONLY, + pirogueDurationMin: 240, + roadAccessNote: null, + latitude: 3.7501, + longitude: -51.5801, + capacity: 4, + description: + "Côté Oyapock, vis-à-vis du Brésil, quatre heures de pirogue depuis Saint-Georges. Pour ceux qui veulent vraiment dormir loin. Saison sèche uniquement : étiage rend l'Oyapock difficile en mai-juin.", + }, + { + slug: "demo-karbe-mahury-ce-hopital", + title: "Karbé du CE — bord du Mahury", + ownerIdx: 2, + river: "Mahury", + embarkPoint: "Rémire-Montjoly, base nautique", + accessType: AccessType.ROAD_AND_RIVER, + pirogueDurationMin: 15, + roadAccessNote: + "Accès depuis Rémire-Montjoly, route asphaltée jusqu'à la base nautique. Parking signalé.", + latitude: 4.8801, + longitude: -52.2691, + capacity: 12, + description: + "Le carbet du Comité Social de l'Hôpital de Cayenne, réservé aux agents en semaine et ouvert au public le week-end (sauf jours fériés). Spacieux, équipé pour familles : groupe électrogène, frigo, plancha.", + }, + { + slug: "demo-karbe-kourou-couleuvre", + title: "Karbé Couleuvre", + ownerIdx: 1, + river: "Kourou", + embarkPoint: "Pont du Kourou", + accessType: AccessType.ROAD_AND_RIVER, + pirogueDurationMin: null, + roadAccessNote: + "100 % accessible par la voiture. Garez-vous au pont du Kourou, comptez 10 min à pied par la berge.", + latitude: 5.1568, + longitude: -52.6504, + capacity: 3, + description: + "Petit carbet pour couple, sur la berge du Kourou. Accès route uniquement, idéal nuit improvisée après le boulot. Pas de pirogue ici, juste un hamac, un livre, le clapotis.", + }, +]; + +const DEMO_PASSWORD = "demo-karbe-2026"; + +async function ensureOwner(idx: 0 | 1 | 2): Promise { + const owner = DEMO_OWNERS[idx]; + const existing = await prisma.user.findUnique({ where: { email: owner.email } }); + if (existing) return existing.id; + + const passwordHash = await hashPassword(DEMO_PASSWORD); + const created = await prisma.user.create({ + data: { + email: owner.email, + passwordHash, + firstName: owner.firstName, + lastName: owner.lastName, + phone: owner.phone, + role: UserRole.OWNER, + isActive: true, + }, + }); + return created.id; +} + +export async function seedDemoCarbets(): Promise<{ created: number; existing: number }> { + const ownerIds: string[] = []; + for (const idx of [0, 1, 2] as const) { + ownerIds.push(await ensureOwner(idx)); + } + + let created = 0; + let existing = 0; + for (const carbet of DEMO_CARBETS) { + const found = await prisma.carbet.findUnique({ where: { slug: carbet.slug } }); + if (found) { + // Si désactivé/archivé puis re-activé, on remet en PUBLISHED. + if (found.status !== CarbetStatus.PUBLISHED) { + await prisma.carbet.update({ + where: { id: found.id }, + data: { status: CarbetStatus.PUBLISHED }, + }); + } + existing += 1; + continue; + } + await prisma.carbet.create({ + data: { + slug: carbet.slug, + title: carbet.title, + description: carbet.description, + river: carbet.river, + embarkPoint: carbet.embarkPoint, + accessType: carbet.accessType, + pirogueDurationMin: carbet.pirogueDurationMin, + roadAccessNote: carbet.roadAccessNote, + latitude: carbet.latitude, + longitude: carbet.longitude, + capacity: carbet.capacity, + status: CarbetStatus.PUBLISHED, + ownerId: ownerIds[carbet.ownerIdx], + }, + }); + created += 1; + } + return { created, existing }; +} + +export async function archiveDemoCarbets(): Promise { + const result = await prisma.carbet.updateMany({ + where: { slug: { startsWith: "demo-karbe-" } }, + data: { status: CarbetStatus.ARCHIVED }, + }); + return result.count; +} From bc571b38d1279c5219f5ec1728a46c787a659394 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 03:00:52 +0000 Subject: [PATCH 05/68] =?UTF-8?q?chore(prisma):=20d=C3=A9clare=20enum=20Ac?= =?UTF-8?q?cessType=20(oubli=C3=A9=20dans=20PR#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b8c9cd6..84d2e51 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,11 @@ enum SubscriptionStatus { CANCELED } +enum AccessType { + ROAD_AND_RIVER + RIVER_ONLY +} + model Organization { id String @id @default(cuid()) name String From be2391998d55a26d1b319c735ad10e7b15ec2e80 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 08:50:26 +0000 Subject: [PATCH 06/68] feat(plugins): seasonality + min-stay (Phase 3.2 + 3.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin seasonality : - Migration : Carbet.seasonalConstraints JSONB nullable - lib/seasonality.ts : enum Season (DRY|LOW_WATER|WET), currentSeason() helper Guyane (juil-sept sèche, oct-nov étiage, déc-juin pluies), parseSeasonalConstraints, isCurrentlyOpen, SEASON_META (label/emoji/tone) - Composant server, gated par plugin, ajouté dans layout au-dessus de tout le contenu — bandeau couleur+emoji+message contextuel Plugin min-stay : - Migration : Carbet.minStayNights, maxStayNights, minCapacity nullable - Composant client, gated par plugin — pill text '2 nuits minimum', '2-7 nuits', 'groupe 4+ recommandé' - Carbet card et fiche enrichies avec les contraintes Tous deux désactivables : sans le toggle, comportement legacy inchangé. --- .../migration.sql | 7 ++ src/app/carbets/[slug]/page.tsx | 13 ++++ src/app/layout.tsx | 6 +- src/components/SeasonBanner.tsx | 41 +++++++++++ src/components/StayConstraints.tsx | 47 ++++++++++++ src/lib/carbet-public.ts | 12 ++++ src/lib/seasonality.ts | 71 +++++++++++++++++++ 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql create mode 100644 src/components/SeasonBanner.tsx create mode 100644 src/components/StayConstraints.tsx create mode 100644 src/lib/seasonality.ts diff --git a/prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql b/prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql new file mode 100644 index 0000000..0cf671c --- /dev/null +++ b/prisma/migrations/20260531120000_add_seasonality_and_min_stay/migration.sql @@ -0,0 +1,7 @@ +-- Plugin seasonality + min-stay : champs sur Carbet + +ALTER TABLE "Carbet" + ADD COLUMN "seasonalConstraints" JSONB, + ADD COLUMN "minStayNights" INTEGER, + ADD COLUMN "maxStayNights" INTEGER, + ADD COLUMN "minCapacity" INTEGER; diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index 6acb95b..227b305 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -16,6 +16,7 @@ import { CarbetGallery } from "../_components/carbet-gallery"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; +import { StayConstraints } from "@/components/StayConstraints"; type PageProps = { params: Promise<{ slug: string }>; @@ -197,6 +198,18 @@ export default async function PublicCarbetPage({ params }: PageProps) { {formatCoordinate(carbet.longitude)}
+ {(carbet.minStayNights || carbet.maxStayNights || carbet.minCapacity) ? ( +
+
Séjour
+
+ +
+
+ ) : null}
Capacité
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d06bd3f..8b05af1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono, Cormorant_Garamond } from "next/font/google"; import "./globals.css"; import { PluginProvider } from "@/lib/plugins/client"; import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server"; +import { SeasonBanner } from "@/components/SeasonBanner"; // Le layout interroge la DB Plugin à chaque request → rendu dynamique forcé. // Sans ça, le layout (et donc data-theme + enabledKeys passés au client) est @@ -76,7 +77,10 @@ export default async function RootLayout({ data-theme={themeGuyane ? "guyane" : undefined} className="min-h-full flex flex-col font-sans" > - {children} + + + {children} + ); diff --git a/src/components/SeasonBanner.tsx b/src/components/SeasonBanner.tsx new file mode 100644 index 0000000..d489cd3 --- /dev/null +++ b/src/components/SeasonBanner.tsx @@ -0,0 +1,41 @@ +import { isPluginEnabled } from "@/lib/plugins/server"; +import { currentSeason, SEASON_META } from "@/lib/seasonality"; + +/** + * Bandeau saison — affiché en haut de la home et de /carbets si le plugin + * `seasonality` est activé. Server component pur, pas de fetch DB. + */ +export async function SeasonBanner() { + if (!(await isPluginEnabled("seasonality"))) return null; + const season = currentSeason(); + const meta = SEASON_META[season]; + + const messages: Record = { + DRY: + "Conditions optimales : fleuves navigables, pistes route en bon état, lever de soleil sur l'eau brûlante.", + LOW_WATER: + "Étiage en cours : les carbets fleuve uniquement peuvent ne pas être accessibles. Filtre dispo en page recherche.", + WET: + "Pluies fréquentes : la jungle est dense et vivante, prévoir un véhicule adapté pour les carbets route+fleuve.", + }; + + const tones = { + ok: "bg-[var(--color-karbe-canopy-700)] text-[var(--color-karbe-bone)]", + warn: "bg-[var(--color-karbe-laterite-500)] text-[var(--color-karbe-bone)]", + info: "bg-[var(--color-karbe-maroni-700)] text-[var(--color-karbe-bone)]", + } as const; + + return ( + + ); +} diff --git a/src/components/StayConstraints.tsx b/src/components/StayConstraints.tsx new file mode 100644 index 0000000..8026bf1 --- /dev/null +++ b/src/components/StayConstraints.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useIsPluginEnabled } from "@/lib/plugins/client"; + +/** + * Composant client qui affiche les contraintes de séjour si le plugin + * `min-stay` est activé. Sinon, retourne null (legacy = pas de contraintes). + */ +export function StayConstraints({ + minNights, + maxNights, + minCapacity, + className = "", +}: { + minNights?: number | null; + maxNights?: number | null; + minCapacity?: number | null; + className?: string; +}) { + const enabled = useIsPluginEnabled("min-stay"); + if (!enabled) return null; + if (!minNights && !maxNights && !minCapacity) return null; + + const parts: string[] = []; + if (minNights && maxNights && minNights !== maxNights) { + parts.push(`${minNights}–${maxNights} nuits`); + } else if (minNights) { + parts.push( + minNights === 1 ? "1 nuit minimum" : `${minNights} nuits minimum`, + ); + } else if (maxNights) { + parts.push(`Max ${maxNights} nuits`); + } + if (minCapacity && minCapacity > 1) { + parts.push(`groupe de ${minCapacity}+ recommandé`); + } + if (!parts.length) return null; + + return ( + + 🌙 {parts.join(" · ")} + + ); +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index 233898c..dc32e1f 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -26,6 +26,10 @@ export type PublicCarbetDetail = { accessType: AccessType; roadAccessNote: string | null; capacity: number; + minStayNights: number | null; + maxStayNights: number | null; + minCapacity: number | null; + seasonalConstraints: unknown; latitude: string; longitude: string; ownerId: string; @@ -53,6 +57,10 @@ export const getPublicCarbet = cache( accessType: true, roadAccessNote: true, capacity: true, + minStayNights: true, + maxStayNights: true, + minCapacity: true, + seasonalConstraints: true, latitude: true, longitude: true, ownerId: true, @@ -85,6 +93,10 @@ export const getPublicCarbet = cache( accessType: carbet.accessType, roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, + minStayNights: carbet.minStayNights, + maxStayNights: carbet.maxStayNights, + minCapacity: carbet.minCapacity, + seasonalConstraints: carbet.seasonalConstraints, latitude: carbet.latitude.toString(), longitude: carbet.longitude.toString(), ownerId: carbet.ownerId, diff --git a/src/lib/seasonality.ts b/src/lib/seasonality.ts new file mode 100644 index 0000000..e63cdf4 --- /dev/null +++ b/src/lib/seasonality.ts @@ -0,0 +1,71 @@ +/** + * Saisons guyanaises — gated par le plugin `seasonality`. + * + * Guyane française : + * - DRY (juillet-septembre) : saison sèche, conditions idéales + * - LOW_WATER (octobre-mi-novembre) : étiage, fleuves bas, certains carbets + * fleuve-only peuvent ne pas être accessibles + * - WET (décembre-juin) : grande saison des pluies, pistes route + * parfois en mauvais état + * + * Volontairement simplifié — la vraie saisonnalité varie un peu selon le + * fleuve. Les contraintes fines vivent dans Carbet.seasonalConstraints. + */ + +export type Season = "DRY" | "LOW_WATER" | "WET"; + +export function currentSeason(date = new Date()): Season { + const month = date.getMonth() + 1; // 1..12 + if (month >= 7 && month <= 9) return "DRY"; + if (month === 10 || month === 11) return "LOW_WATER"; + return "WET"; +} + +export type SeasonalConstraints = { + closedInLowWater?: boolean; + closedSeasons?: Season[]; + note?: string; +}; + +export function parseSeasonalConstraints(value: unknown): SeasonalConstraints | null { + if (!value || typeof value !== "object") return null; + const v = value as Record; + const out: SeasonalConstraints = {}; + if (typeof v.closedInLowWater === "boolean") out.closedInLowWater = v.closedInLowWater; + if (Array.isArray(v.closedSeasons)) { + out.closedSeasons = v.closedSeasons.filter( + (s): s is Season => s === "DRY" || s === "LOW_WATER" || s === "WET", + ); + } + if (typeof v.note === "string") out.note = v.note; + return out; +} + +export function isCurrentlyOpen( + constraints: SeasonalConstraints | null, + date = new Date(), +): boolean { + if (!constraints) return true; + const s = currentSeason(date); + if (constraints.closedInLowWater && s === "LOW_WATER") return false; + if (constraints.closedSeasons?.includes(s)) return false; + return true; +} + +export const SEASON_META: Record = { + DRY: { + label: "Saison sèche", + emoji: "☀️", + tone: "ok", + }, + LOW_WATER: { + label: "Étiage", + emoji: "⚠️", + tone: "warn", + }, + WET: { + label: "Saison des pluies", + emoji: "🌧", + tone: "info", + }, +}; From 3405f0047681d5f6f5426b47aee7333c38422b64 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 08:52:46 +0000 Subject: [PATCH 07/68] =?UTF-8?q?chore(prisma):=20ajoute=20minStayNights/m?= =?UTF-8?q?axStayNights/minCapacity/seasonalConstraints=20au=20mod=C3=A8le?= =?UTF-8?q?=20Carbet=20(oubli=20PR#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 84d2e51..a7d8c9e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,6 +118,13 @@ model Carbet { // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). roadAccessNote String? capacity Int + // Contraintes séjour (plugin min-stay). null = pas de contrainte. + minStayNights Int? + maxStayNights Int? + minCapacity Int? + // Contraintes saisonnières (plugin seasonality). JSON libre, schéma type : + // { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string } + seasonalConstraints Json? status CarbetStatus @default(DRAFT) lastBookedAt DateTime? createdAt DateTime @default(now()) From a7761ca32373c0bec1e0f8a7d612e7f7c1c71234 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 08:59:46 +0000 Subject: [PATCH 08/68] chore: wire StayConstraints + minStayNights dans carbet-card + search (oubli PR#30) --- src/app/carbets/_components/carbet-card.tsx | 8 ++++++++ src/lib/carbet-search.ts | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index a757b1a..9a6a53b 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -4,6 +4,7 @@ import type { CarbetSearchResult } from "@/lib/carbet-search"; import { formatPirogueDuration, truncate } from "@/lib/format"; import { formatAverageRating } from "@/lib/reviews"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; +import { StayConstraints } from "@/components/StayConstraints"; import { StarRating } from "./star-rating"; @@ -41,6 +42,13 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) { Fleuve {carbet.river} · {carbet.capacity} voyageur {carbet.capacity > 1 ? "s" : ""}

+
+ +
{carbet.reviewCount > 0 && carbet.averageRating !== null ? (

diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index aa8b4be..0f25da3 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -82,6 +82,9 @@ export type CarbetSearchResult = { accessType: AccessType; roadAccessNote: string | null; capacity: number; + minStayNights: number | null; + maxStayNights: number | null; + minCapacity: number | null; description: string; coverUrl: string | null; mediaCount: number; @@ -142,6 +145,9 @@ export async function searchCarbets( accessType: true, roadAccessNote: true, capacity: true, + minStayNights: true, + maxStayNights: true, + minCapacity: true, description: true, media: { orderBy: { sortOrder: "asc" }, @@ -169,6 +175,9 @@ export async function searchCarbets( accessType: carbet.accessType, roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, + minStayNights: carbet.minStayNights, + maxStayNights: carbet.maxStayNights, + minCapacity: carbet.minCapacity, description: carbet.description, coverUrl: carbet.media[0]?.s3Url ?? null, mediaCount: carbet._count.media, From 68f37f554f222ab9eb55eb77daeeaae4bd088345 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 10:12:13 +0000 Subject: [PATCH 09/68] feat(plugins): content-pages + legal-pages (Phase 4.1 + 4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin content-pages : - Modèle Prisma ContentPage (slug PK, title, body markdown, category, published) - lib/content-pages.ts : helpers upsert/get/list/unpublish - lib/markdown.ts : mini-renderer markdown server-side sans deps externe (h1-h3, paragraphes, gras/italique, liens, listes ul/ol, hr, blockquote, échappement HTML) - ContentPageRenderer server component, applique le theme Guyane (font-serif) - 5 pages seedées : /a-propos, /faq, /comment-ca-marche, /pour-comites-entreprise, /devenir-loueur - Routes publiques + force-dynamic + guard requirePluginOr404 Plugin legal-pages : - Réutilise le même modèle ContentPage, catégorie 'legal' - 3 pages seedées : /cgv, /mentions-legales, /politique-de-confidentialite (contenu de base, à valider par avocat avant prod réelle) Admin : - /admin/content-pages : table par catégorie, statut publié/dépublié - /admin/content-pages/[slug] : éditeur markdown + toggle publié - PATCH /api/admin/content-pages/[slug] Hooks plugin : - onEnable seed + republish toutes les pages - onDisable dépublie toute la catégorie sans la supprimer (preserve les edits) --- .../migration.sql | 16 ++ prisma/schema.prisma | 16 ++ src/app/a-propos/page.tsx | 18 ++ .../[slug]/_components/EditorForm.tsx | 93 +++++++++ src/app/admin/content-pages/[slug]/page.tsx | 47 +++++ src/app/admin/content-pages/page.tsx | 62 ++++++ .../api/admin/content-pages/[slug]/route.ts | 39 ++++ src/app/cgv/page.tsx | 18 ++ src/app/comment-ca-marche/page.tsx | 18 ++ src/app/devenir-loueur/page.tsx | 18 ++ src/app/faq/page.tsx | 18 ++ src/app/mentions-legales/page.tsx | 18 ++ src/app/politique-de-confidentialite/page.tsx | 18 ++ src/app/pour-comites-entreprise/page.tsx | 18 ++ src/components/ContentPageRenderer.tsx | 31 +++ src/lib/content-pages.ts | 110 +++++++++++ src/lib/markdown.ts | 151 ++++++++++++++ src/lib/plugins/hooks.ts | 36 ++++ .../plugins/seeds/content-pages-default.ts | 185 +++++++++++++++++ src/lib/plugins/seeds/legal-pages-default.ts | 186 ++++++++++++++++++ 20 files changed, 1116 insertions(+) create mode 100644 prisma/migrations/20260531180000_add_content_pages/migration.sql create mode 100644 src/app/a-propos/page.tsx create mode 100644 src/app/admin/content-pages/[slug]/_components/EditorForm.tsx create mode 100644 src/app/admin/content-pages/[slug]/page.tsx create mode 100644 src/app/admin/content-pages/page.tsx create mode 100644 src/app/api/admin/content-pages/[slug]/route.ts create mode 100644 src/app/cgv/page.tsx create mode 100644 src/app/comment-ca-marche/page.tsx create mode 100644 src/app/devenir-loueur/page.tsx create mode 100644 src/app/faq/page.tsx create mode 100644 src/app/mentions-legales/page.tsx create mode 100644 src/app/politique-de-confidentialite/page.tsx create mode 100644 src/app/pour-comites-entreprise/page.tsx create mode 100644 src/components/ContentPageRenderer.tsx create mode 100644 src/lib/content-pages.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/plugins/seeds/content-pages-default.ts create mode 100644 src/lib/plugins/seeds/legal-pages-default.ts diff --git a/prisma/migrations/20260531180000_add_content_pages/migration.sql b/prisma/migrations/20260531180000_add_content_pages/migration.sql new file mode 100644 index 0000000..4306682 --- /dev/null +++ b/prisma/migrations/20260531180000_add_content_pages/migration.sql @@ -0,0 +1,16 @@ +-- Plugin content-pages + legal-pages : table ContentPage + +CREATE TABLE "ContentPage" ( + "slug" TEXT PRIMARY KEY, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "lang" TEXT NOT NULL DEFAULT 'fr', + "category" TEXT NOT NULL DEFAULT 'general', + "published" BOOLEAN NOT NULL DEFAULT true, + "lastEditedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL +); + +CREATE INDEX "ContentPage_category_idx" ON "ContentPage" ("category"); +CREATE INDEX "ContentPage_published_idx" ON "ContentPage" ("published"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a7d8c9e..4fd694e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -280,3 +280,19 @@ model Plugin { @@index([category]) @@index([enabled]) } + +model ContentPage { + slug String @id + title String + body String + lang String @default("fr") + // 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...) + category String @default("general") + published Boolean @default(true) + lastEditedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) + @@index([published]) +} diff --git a/src/app/a-propos/page.tsx b/src/app/a-propos/page.tsx new file mode 100644 index 0000000..e4a36b1 --- /dev/null +++ b/src/app/a-propos/page.tsx @@ -0,0 +1,18 @@ +import { notFound } from "next/navigation"; +import { getContentPage } from "@/lib/content-pages"; +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"); + return { title: page?.title ?? "À propos" }; +} + +export default async function AboutPage() { + if (!(await isPluginEnabled("content-pages"))) notFound(); + const page = await getContentPage("a-propos"); + if (!page) notFound(); + return ; +} diff --git a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx new file mode 100644 index 0000000..64e3818 --- /dev/null +++ b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +type Page = { + slug: string; + title: string; + body: string; + category: string; + published: boolean; +}; + +export default function EditorForm({ page }: { page: Page }) { + const router = useRouter(); + const [title, setTitle] = useState(page.title); + const [body, setBody] = useState(page.body); + const [published, setPublished] = useState(page.published); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + async function save() { + setBusy(true); + setMsg(null); + setErr(null); + try { + const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body, published }), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j?.error || `HTTP ${res.status}`); + } + setMsg("Sauvegardé."); + router.refresh(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +

+ + +