From bc571b38d1279c5219f5ec1728a46c787a659394 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 03:00:52 +0000 Subject: [PATCH 01/65] =?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 02/65] 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 03/65] =?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 04/65] 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 05/65] 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 ( +

+ + +