From 62cc4647387f489c155ad7e76d096547aaa80f02 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 22:17:10 +0000 Subject: [PATCH 01/61] =?UTF-8?q?feat(plugins):=20foundation=20syst=C3=A8m?= =?UTF-8?q?e=20Plugin=20Karb=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modèle Prisma Plugin (key, name, description, category, version, enabled, config JSONB, migrationsApplied, timestamps) + migration SQL - PluginRegistry (src/lib/plugins/registry.ts) avec 12 plugins déclarés : visuels (theme-guyane, landing-hero, landing-sections, image-gallery-seed, demo-carbets-seed), métier (access-type, seasonality, pirogue-providers, min-stay), contenus (content-pages, legal-pages), i18n (i18n-fr-en) - Server helpers (server.ts) : sync, isEnabled, getEnabledKeys, toggle avec hooks onEnable/onDisable, updateConfig, cache 5s - Client bridge (client.tsx) : PluginProvider + useIsPluginEnabled - Composant - Guard requirePluginOr404 pour pages et routes - Page admin /admin/plugins avec table toggle par catégorie + édition config - Route PATCH /api/admin/plugins/[key] + GET - Layout async qui sync registry + passe enabledKeys au PluginProvider Tous plugins en enabled=false par défaut, activation pilotée depuis l'admin. --- .../migration.sql | 19 +++ prisma/schema.prisma | 18 +++ .../plugins/_components/PluginToggleTable.tsx | 102 ++++++++++++++ src/app/admin/plugins/page.tsx | 26 ++++ src/app/api/admin/plugins/[key]/route.ts | 39 ++++++ src/app/layout.tsx | 19 ++- src/components/IfPluginEnabled.tsx | 31 +++++ src/lib/plugins/client.tsx | 28 ++++ src/lib/plugins/guard.ts | 22 +++ src/lib/plugins/hooks.ts | 19 +++ src/lib/plugins/registry.ts | 127 +++++++++++++++++ src/lib/plugins/server.ts | 129 ++++++++++++++++++ 12 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260530200000_add_plugin_system/migration.sql create mode 100644 src/app/admin/plugins/_components/PluginToggleTable.tsx create mode 100644 src/app/admin/plugins/page.tsx create mode 100644 src/app/api/admin/plugins/[key]/route.ts create mode 100644 src/components/IfPluginEnabled.tsx create mode 100644 src/lib/plugins/client.tsx create mode 100644 src/lib/plugins/guard.ts create mode 100644 src/lib/plugins/hooks.ts create mode 100644 src/lib/plugins/registry.ts create mode 100644 src/lib/plugins/server.ts diff --git a/prisma/migrations/20260530200000_add_plugin_system/migration.sql b/prisma/migrations/20260530200000_add_plugin_system/migration.sql new file mode 100644 index 0000000..5437f7c --- /dev/null +++ b/prisma/migrations/20260530200000_add_plugin_system/migration.sql @@ -0,0 +1,19 @@ +-- Foundation : système Plugin Karbé + +CREATE TABLE "Plugin" ( + "key" TEXT PRIMARY KEY, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "category" TEXT NOT NULL, + "version" TEXT NOT NULL DEFAULT '0.1.0', + "enabled" BOOLEAN NOT NULL DEFAULT false, + "config" JSONB NOT NULL DEFAULT '{}', + "migrationsApplied" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastEnabledAt" TIMESTAMP(3), + "lastDisabledAt" TIMESTAMP(3) +); + +CREATE INDEX "Plugin_category_idx" ON "Plugin" ("category"); +CREATE INDEX "Plugin_enabled_idx" ON "Plugin" ("enabled"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee7a327..c908b52 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -244,3 +244,21 @@ model Review { @@index([carbetId]) @@index([authorId]) } + +model Plugin { + key String @id + name String + description String + category String + version String @default("0.1.0") + enabled Boolean @default(false) + config Json @default("{}") + migrationsApplied String[] @default([]) + installedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastEnabledAt DateTime? + lastDisabledAt DateTime? + + @@index([category]) + @@index([enabled]) +} diff --git a/src/app/admin/plugins/_components/PluginToggleTable.tsx b/src/app/admin/plugins/_components/PluginToggleTable.tsx new file mode 100644 index 0000000..70c77ac --- /dev/null +++ b/src/app/admin/plugins/_components/PluginToggleTable.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState, useTransition } from "react"; + +interface PluginRow { + key: string; + name: string; + description: string; + category: string; + version: string; + enabled: boolean; + config: Record; +} + +const CATEGORY_LABEL: Record = { + visual: "Visuels", + business: "Métier", + content: "Contenus", + i18n: "Internationalisation", + core: "Core", +}; + +export default function PluginToggleTable({ plugins: initial }: { plugins: PluginRow[] }) { + const [plugins, setPlugins] = useState(initial); + const [pending, startTransition] = useTransition(); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(null); + + const byCategory = plugins.reduce>((acc, p) => { + (acc[p.category] ??= []).push(p); + return acc; + }, {}); + + async function toggle(key: string, next: boolean) { + setError(null); + setBusyKey(key); + try { + const res = await fetch(`/api/admin/plugins/${encodeURIComponent(key)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || `HTTP ${res.status}`); + } + const updated = await res.json(); + startTransition(() => { + setPlugins((curr) => + curr.map((p) => (p.key === key ? { ...p, enabled: !!updated.enabled } : p)), + ); + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyKey(null); + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + {Object.entries(byCategory).map(([category, rows]) => ( +
+

+ {CATEGORY_LABEL[category] ?? category} +

+
    + {rows.map((p) => ( +
  • +
    +
    + {p.name} + {p.key} + v{p.version} +
    +

    {p.description}

    +
    + +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/src/app/admin/plugins/page.tsx b/src/app/admin/plugins/page.tsx new file mode 100644 index 0000000..37195f7 --- /dev/null +++ b/src/app/admin/plugins/page.tsx @@ -0,0 +1,26 @@ +import { requireRole } from "@/lib/authorization"; +import { UserRole } from "@/generated/prisma/enums"; +import { listAllPlugins, syncPluginsFromRegistry } from "@/lib/plugins/server"; +import PluginToggleTable from "./_components/PluginToggleTable"; + +export const dynamic = "force-dynamic"; + +export default async function PluginsAdminPage() { + await requireRole([UserRole.ADMIN]); + // S'assure que tous les plugins du registry sont en DB. + await syncPluginsFromRegistry(); + const plugins = await listAllPlugins(); + + return ( +
+

Plugins Karbé

+

+ Active ou désactive chaque module. Les changements prennent effet immédiatement (cache 5 s). + L'onEnable/onDisable est exécuté avant la bascule. +

+
+ +
+
+ ); +} diff --git a/src/app/api/admin/plugins/[key]/route.ts b/src/app/api/admin/plugins/[key]/route.ts new file mode 100644 index 0000000..66dc88e --- /dev/null +++ b/src/app/api/admin/plugins/[key]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireRole } from "@/lib/authorization"; +import { UserRole } from "@/generated/prisma/enums"; +import { findDescriptor } from "@/lib/plugins/registry"; +import { togglePlugin, updatePluginConfig, getPluginState } from "@/lib/plugins/server"; + +const patchSchema = z.object({ + enabled: z.boolean().optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); + +export async function PATCH(req: Request, ctx: { params: Promise<{ key: string }> }) { + await requireRole([UserRole.ADMIN]); + const { key } = await ctx.params; + if (!findDescriptor(key)) { + return NextResponse.json({ error: "Unknown plugin" }, { status: 404 }); + } + const parsed = patchSchema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + let state = await getPluginState(key); + if (parsed.data.enabled !== undefined) { + state = await togglePlugin(key, parsed.data.enabled); + } + if (parsed.data.config !== undefined) { + state = await updatePluginConfig(key, parsed.data.config); + } + return NextResponse.json(state); +} + +export async function GET(_req: Request, ctx: { params: Promise<{ key: string }> }) { + await requireRole([UserRole.ADMIN]); + const { key } = await ctx.params; + const state = await getPluginState(key); + if (!state) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(state); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a671161..8ddbb33 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { PluginProvider } from "@/lib/plugins/client"; +import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -32,17 +34,30 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + // Plugins Karbé : sync registry → DB puis charge la liste activée pour le client. + // En cas d'erreur DB (build statique sans DB par ex.), on retombe en mode "aucun + // plugin activé" pour ne pas casser le rendu. + let enabledKeys: string[] = []; + try { + await syncPluginsFromRegistry(); + enabledKeys = await getEnabledPluginKeys(); + } catch { + enabledKeys = []; + } + return ( - {children} + + {children} + ); } diff --git a/src/components/IfPluginEnabled.tsx b/src/components/IfPluginEnabled.tsx new file mode 100644 index 0000000..9b6fee1 --- /dev/null +++ b/src/components/IfPluginEnabled.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useIsPluginEnabled } from "@/lib/plugins/client"; + +/** + * Composant client : affiche `children` uniquement si le plugin est activé. + * Le `` doit être présent en amont (layout) avec la liste + * des plugins activés calculée côté serveur. + * + * Usage : + * + * + * + * + * }> + * + * + */ +export function IfPluginEnabled({ + plugin, + fallback = null, + children, +}: { + plugin: string; + fallback?: ReactNode; + children: ReactNode; +}) { + const enabled = useIsPluginEnabled(plugin); + return <>{enabled ? children : fallback}; +} diff --git a/src/lib/plugins/client.tsx b/src/lib/plugins/client.tsx new file mode 100644 index 0000000..7f27540 --- /dev/null +++ b/src/lib/plugins/client.tsx @@ -0,0 +1,28 @@ +/** + * Plugin Karbé — bridge client. + * + * Le client ne lit JAMAIS la DB. Il reçoit l'état des plugins depuis un + * server component qui appelle `getEnabledPluginKeys()` et passe la liste en + * prop ou via le composant ``. + */ + +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +const PluginContext = createContext>(new Set()); + +export function PluginProvider({ + enabledKeys, + children, +}: { + enabledKeys: string[]; + children: ReactNode; +}) { + return {children}; +} + +export function useIsPluginEnabled(key: string): boolean { + const set = useContext(PluginContext); + return set.has(key); +} diff --git a/src/lib/plugins/guard.ts b/src/lib/plugins/guard.ts new file mode 100644 index 0000000..bdafd6a --- /dev/null +++ b/src/lib/plugins/guard.ts @@ -0,0 +1,22 @@ +/** + * Plugin Karbé — guard pour pages et routes API. + * + * Côté server component (page) : + * import { requirePluginOr404 } from "@/lib/plugins/guard"; + * export default async function Page() { + * await requirePluginOr404("landing-hero"); + * ... + * } + * + * Côté route handler : + * if (!(await isPluginEnabled("access-type"))) return new Response("Not Found", { status: 404 }); + */ + +import "server-only"; +import { notFound } from "next/navigation"; +import { isPluginEnabled } from "./server"; + +export async function requirePluginOr404(key: string): Promise { + const ok = await isPluginEnabled(key); + if (!ok) notFound(); +} diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts new file mode 100644 index 0000000..ad20403 --- /dev/null +++ b/src/lib/plugins/hooks.ts @@ -0,0 +1,19 @@ +/** + * Plugin Karbé — hooks d'activation/désactivation par plugin. + * + * Chaque plugin peut déclarer un `onEnable` et/ou `onDisable` ici. Exemples + * d'usage : seed initial des carbets démo (au enable), soft-delete des + * carbets démo (au disable). Les hooks ne sont **pas** des migrations DB — + * pour ça on passe par les fichiers prisma/migrations. + */ + +export type PluginHook = () => Promise; + +export interface PluginHookSet { + onEnable?: PluginHook; + 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 = {}; diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts new file mode 100644 index 0000000..402ac92 --- /dev/null +++ b/src/lib/plugins/registry.ts @@ -0,0 +1,127 @@ +/** + * Plugin Karbé — registry statique des plugins disponibles. + * + * Chaque plugin déclaré ici sera synchronisé en DB au démarrage (insertion si absent, + * sans toucher au flag `enabled` existant). L'état (enabled / config) est piloté + * depuis /admin/plugins. Le code des plugins est TOUJOURS présent dans le bundle ; + * l'activation au runtime conditionne juste l'exposition des pages, routes et composants. + */ + +export type PluginCategory = "core" | "visual" | "business" | "content" | "i18n"; + +export interface PluginDescriptor { + key: string; + name: string; + description: string; + category: PluginCategory; + version: string; +} + +export const PLUGINS: PluginDescriptor[] = [ + // Visuels + { + key: "theme-guyane", + name: "Thème Guyane", + description: + "Palette tropicale (vert canopée, eau Maroni, ocre latérite, bois karbé) + typographies display + tokens Tailwind.", + category: "visual", + version: "0.1.0", + }, + { + key: "landing-hero", + name: "Hero d'accueil", + description: + "Refonte de la page d'accueil avec hero plein écran, claim, CTA double Découvrir/Proposer.", + category: "visual", + version: "0.1.0", + }, + { + key: "landing-sections", + name: "Sections d'accueil", + description: + "Sections « 2 expériences », « Comment ça marche », « Pour comités d'entreprise », « Témoignages », footer riche.", + category: "visual", + version: "0.1.0", + }, + { + key: "image-gallery-seed", + name: "Galerie d'images seed", + description: + "8 images photo-réalistes générées (carbets, intérieurs, pirogue) stockées dans MinIO. Illustrations vectorielles pour les sections.", + category: "visual", + version: "0.1.0", + }, + { + key: "demo-carbets-seed", + name: "Carbets de démo", + description: + "5-8 carbets d'exemple avec photos, GPS réels, types d'accès variés. Désactivation = soft-delete via tag.", + category: "visual", + version: "0.1.0", + }, + + // Métier + { + key: "access-type", + name: "Type d'accès route/fleuve", + description: + "Distinction ROAD_AND_RIVER vs RIVER_ONLY (badge, filtre recherche, fiche enrichie). Migration soft.", + category: "business", + version: "0.1.0", + }, + { + key: "seasonality", + name: "Saisonnalité", + description: + "Saison sèche / pluies / étiage. Bandeau d'alerte, dispo conditionnelle, filtre « ouvert maintenant ».", + category: "business", + version: "0.1.0", + }, + { + key: "pirogue-providers", + name: "Prestataires pirogue", + description: + "Partenaires pirogue + mode transport sur Carbet (OWNER_PROVIDES / SELF_ARRANGE / PARTNER_PROVIDER).", + category: "business", + version: "0.1.0", + }, + { + key: "min-stay", + name: "Durée min/max séjour", + description: + "Contraintes nuits min/max, capacité min, règles week-end CE vs semaine public mappées dans Availability.", + category: "business", + version: "0.1.0", + }, + + // Contenus / i18n + { + key: "content-pages", + name: "Pages de contenu", + description: + "Pages markdown éditables depuis l'admin (À propos, FAQ, Comment ça marche, Pour CE, Devenir loueur).", + category: "content", + version: "0.1.0", + }, + { + key: "i18n-fr-en", + name: "i18n FR + EN", + description: "Routes [locale]/, détection navigateur, switcher header. Plugin désactivable = FR pur sans préfixe.", + category: "i18n", + version: "0.1.0", + }, + { + key: "legal-pages", + name: "Pages légales", + description: "CGV, RGPD, mentions légales (absorbe SYS-9). Markdown + rendu statique.", + category: "content", + version: "0.1.0", + }, +]; + +export const PLUGIN_KEYS = PLUGINS.map((p) => p.key); +export type PluginKey = (typeof PLUGIN_KEYS)[number]; + +export function findDescriptor(key: string): PluginDescriptor | undefined { + return PLUGINS.find((p) => p.key === key); +} diff --git a/src/lib/plugins/server.ts b/src/lib/plugins/server.ts new file mode 100644 index 0000000..422a0b7 --- /dev/null +++ b/src/lib/plugins/server.ts @@ -0,0 +1,129 @@ +/** + * Plugin Karbé — accès serveur à l'état des plugins. + * + * - getPluginState(key): lit la DB (avec cache 5s pour éviter les hammer). + * - isPluginEnabled(key): helper booléen. + * - syncPluginsFromRegistry(): à appeler au démarrage pour insérer les plugins + * nouvellement ajoutés au code (sans toucher aux flags existants). + * - togglePlugin(key, enabled): admin action avec hook onEnable/onDisable. + */ + +import "server-only"; +import { prisma } from "@/lib/prisma"; +import { PLUGINS, type PluginDescriptor } from "./registry"; +import { pluginHooks } from "./hooks"; + +type PluginRow = { + key: string; + enabled: boolean; + config: Record; + version: string; + category: string; + name: string; + description: string; +}; + +let cache: Map | null = null; +let cacheStamp = 0; +const CACHE_TTL_MS = 5000; + +async function loadAll(): Promise> { + const now = Date.now(); + if (cache && now - cacheStamp < CACHE_TTL_MS) return cache; + const rows = await prisma.plugin.findMany(); + const map = new Map(); + for (const r of rows) { + map.set(r.key, { + key: r.key, + enabled: r.enabled, + config: (r.config ?? {}) as Record, + version: r.version, + category: r.category, + name: r.name, + description: r.description, + }); + } + cache = map; + cacheStamp = now; + return map; +} + +export function invalidatePluginCache(): void { + cache = null; + cacheStamp = 0; +} + +export async function getPluginState(key: string): Promise { + const m = await loadAll(); + return m.get(key) ?? null; +} + +export async function isPluginEnabled(key: string): Promise { + const s = await getPluginState(key); + return !!s?.enabled; +} + +export async function getEnabledPluginKeys(): Promise { + const m = await loadAll(); + return [...m.values()].filter((r) => r.enabled).map((r) => r.key); +} + +export async function listAllPlugins(): Promise { + const m = await loadAll(); + return [...m.values()].sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); +} + +export async function syncPluginsFromRegistry(): Promise { + const existing = new Set((await prisma.plugin.findMany({ select: { key: true } })).map((p) => p.key)); + const toInsert: PluginDescriptor[] = PLUGINS.filter((p) => !existing.has(p.key)); + if (toInsert.length) { + await prisma.plugin.createMany({ + data: toInsert.map((p) => ({ + key: p.key, + name: p.name, + description: p.description, + category: p.category, + version: p.version, + enabled: false, + config: {}, + })), + skipDuplicates: true, + }); + invalidatePluginCache(); + } + // Update name/description/version if the descriptor changed (idempotent). + for (const p of PLUGINS) { + if (existing.has(p.key)) { + await prisma.plugin.update({ + where: { key: p.key }, + data: { name: p.name, description: p.description, category: p.category, version: p.version }, + }); + } + } + invalidatePluginCache(); +} + +export async function togglePlugin(key: string, enabled: boolean): Promise { + const before = await prisma.plugin.findUnique({ where: { key } }); + if (!before) return null; + if (before.enabled === enabled) return await getPluginState(key); + const hook = pluginHooks[key]; + if (enabled && hook?.onEnable) await hook.onEnable(); + if (!enabled && hook?.onDisable) await hook.onDisable(); + await prisma.plugin.update({ + where: { key }, + data: { + enabled, + lastEnabledAt: enabled ? new Date() : before.lastEnabledAt, + lastDisabledAt: !enabled ? new Date() : before.lastDisabledAt, + }, + }); + invalidatePluginCache(); + return await getPluginState(key); +} + +export async function updatePluginConfig(key: string, config: Record): Promise { + await prisma.plugin.update({ where: { key }, data: { config } }); + invalidatePluginCache(); + return await getPluginState(key); +} From d19701e27549400028e401b89de81485b413ef1a Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:19:24 +0000 Subject: [PATCH 02/61] feat(plugins-visuels): theme-guyane + landing-hero + landing-sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 visuals — la page d'accueil prend vie via 3 plugins activables : - theme-guyane : palette tropicale (vert canopée, eau Maroni, ocre latérite, bois karbé, blanc cassé), tokens CSS, typographie display Cormorant Garamond, gradient ambient discret. Activé via body[data-theme=guyane]. - landing-hero : section plein écran avec illustration vectorielle SVG (carbet sur pilotis au crépuscule + fleuve + jungle), claim 'Le karbé qui dort vous attend', double CTA Découvrir / Proposer. Fallback = hero minimaliste actuel. - landing-sections : 5 sections en cascade — 2 expériences (route+fleuve vs expédition fleuve), Comment ça marche (3 étapes), CE (registre coop sans commission), Témoignages (3 stubs), Footer riche avec navigation. Illustrations 100% SVG inline (pas de dépendance image externe). Quand le plugin image-gallery-seed sera activé (Phase 2.4), les photos remplaceront progressivement les SVG. Aucune coupure sur le rendu actuel : tous les plugins visuels sont disabled par défaut, le site garde son look minimaliste tant que l'admin ne les a pas activés depuis /admin/plugins. --- src/app/globals.css | 37 ++++- src/app/layout.tsx | 20 ++- src/app/page.tsx | 83 ++++++++---- src/components/illustrations/CarbetRiver.tsx | 128 ++++++++++++++++++ src/components/illustrations/Icons.tsx | 93 +++++++++++++ src/components/landing/CESection.tsx | 55 ++++++++ src/components/landing/ExperiencesSection.tsx | 75 ++++++++++ src/components/landing/Footer.tsx | 58 ++++++++ src/components/landing/HeroSection.tsx | 53 ++++++++ src/components/landing/HowItWorksSection.tsx | 61 +++++++++ .../landing/TestimonialsSection.tsx | 58 ++++++++ 11 files changed, 691 insertions(+), 30 deletions(-) create mode 100644 src/components/illustrations/CarbetRiver.tsx create mode 100644 src/components/illustrations/Icons.tsx create mode 100644 src/components/landing/CESection.tsx create mode 100644 src/components/landing/ExperiencesSection.tsx create mode 100644 src/components/landing/Footer.tsx create mode 100644 src/components/landing/HeroSection.tsx create mode 100644 src/components/landing/HowItWorksSection.tsx create mode 100644 src/components/landing/TestimonialsSection.tsx diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..83ca72f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -10,10 +10,45 @@ --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --font-serif: var(--font-serif); + + /* === Theme Guyane (plugin theme-guyane) === */ + /* Activé lorsque (gated par le plugin). */ + --color-karbe-canopy-50: #f1f7f1; + --color-karbe-canopy-100: #dceadc; + --color-karbe-canopy-300: #82b58a; + --color-karbe-canopy-500: #2f7a3f; /* vert canopée Guyane */ + --color-karbe-canopy-700: #1f5530; + --color-karbe-canopy-900: #103018; + + --color-karbe-maroni-100: #e6e7d8; /* eau Maroni — vert-brun chargé latérite */ + --color-karbe-maroni-300: #b4b690; + --color-karbe-maroni-500: #8a8a55; + --color-karbe-maroni-700: #5e5e32; + + --color-karbe-laterite-300: #d99c6a; /* ocre latérite rougeâtre */ + --color-karbe-laterite-500: #c46434; + --color-karbe-laterite-700: #8c3d18; + + --color-karbe-wood-300: #c0a280; + --color-karbe-wood-500: #8d6b48; /* bois karbé */ + --color-karbe-wood-700: #5a4329; + + --color-karbe-bone: #f5f1e8; /* blanc cassé chaud */ + --color-karbe-ink: #1a1a14; +} + +body[data-theme="guyane"] { + --background: var(--color-karbe-bone); + --foreground: var(--color-karbe-ink); + font-family: var(--font-geist-sans), system-ui, sans-serif; + background-image: + radial-gradient(ellipse at top, rgba(47, 122, 63, 0.06) 0%, transparent 60%), + radial-gradient(ellipse at bottom, rgba(196, 100, 52, 0.04) 0%, transparent 60%); } @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="guyane"]) { --background: #0a0a0a; --foreground: #ededed; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8ddbb33..c03076a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +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"; @@ -14,6 +14,15 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +// Cormorant Garamond : typographie display pour le theme Guyane (gated par +// le plugin `theme-guyane`). Sert pour `font-serif`. +const cormorant = Cormorant_Garamond({ + variable: "--font-serif", + subsets: ["latin"], + weight: ["400", "500", "600"], + display: "swap", +}); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; export const metadata: Metadata = { @@ -50,12 +59,17 @@ export default async function RootLayout({ enabledKeys = []; } + const themeGuyane = enabledKeys.includes("theme-guyane"); + return ( - + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index d709fb4..5d0099b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,32 +1,63 @@ import Link from "next/link"; +import { IfPluginEnabled } from "@/components/IfPluginEnabled"; +import { HeroSection } from "@/components/landing/HeroSection"; +import { ExperiencesSection } from "@/components/landing/ExperiencesSection"; +import { HowItWorksSection } from "@/components/landing/HowItWorksSection"; +import { CESection } from "@/components/landing/CESection"; +import { TestimonialsSection } from "@/components/landing/TestimonialsSection"; +import { LandingFooter } from "@/components/landing/Footer"; +/** + * Page d'accueil — la majorité du contenu est conditionnée par les plugins : + * - `landing-hero` → hero plein écran + * - `landing-sections` → 2 expériences + comment ça marche + CE + témoignages + footer riche + * + * Si aucun de ces plugins n'est activé, on retombe sur la home historique + * minimaliste (fallback). Activable depuis /admin/plugins. + */ export default function Home() { return ( -
-
-

- Karbé — carbets fluviaux de Guyane -

-

- La marketplace pour louer des carbets le long des fleuves de Guyane. - Connecter voyageurs et hôtes pour des séjours authentiques au cœur de - la forêt amazonienne. -

-
- - Découvrir les carbets - - - Espace hôte - -
-
-
+ <> + +
+

+ Karbé — carbets fluviaux de Guyane +

+

+ La marketplace pour louer des carbets le long des fleuves de Guyane. +

+
+ + Découvrir les carbets + + + Espace hôte + +
+
+ + } + > + +
+ + + + + + + + + ); } diff --git a/src/components/illustrations/CarbetRiver.tsx b/src/components/illustrations/CarbetRiver.tsx new file mode 100644 index 0000000..f60bece --- /dev/null +++ b/src/components/illustrations/CarbetRiver.tsx @@ -0,0 +1,128 @@ +/** + * Illustration vectorielle — carbet sur pilotis vu de l'eau, au crépuscule. + * Pure SVG, aucune dépendance externe. Style ligne claire stylisé. + */ +export function CarbetRiver({ className = "" }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + {/* sky */} + + + {/* sun reflection */} + + + {/* distant jungle band */} + + + {/* water */} + + + {/* water sun reflection lines */} + + + + + + + {/* carbet shadow on water */} + + + {/* stilts */} + + + + + + + + {/* carbet floor */} + + + + {/* carbet wall */} + + + {/* opening */} + + + {/* warm light from inside */} + + + {/* roof — palm thatch */} + + + {/* roof thatch lines */} + + + + + + + + + + {/* palm tree on the right */} + + + {/* leaves */} + + + + + + + + + + + {/* small palm left */} + + + + + + + + + + {/* foreground water ripples */} + + + + + + + + {/* first stars */} + + + + + + + ); +} diff --git a/src/components/illustrations/Icons.tsx b/src/components/illustrations/Icons.tsx new file mode 100644 index 0000000..b7ac914 --- /dev/null +++ b/src/components/illustrations/Icons.tsx @@ -0,0 +1,93 @@ +/** + * Mini-illustrations vectorielles — icônes thématiques Karbé (route, pirogue, + * hamac, étoiles, palme, boussole, fleuve). Cohérentes ligne claire tropicale. + */ + +const baseProps = { + viewBox: "0 0 64 64", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true as const, +}; + +export function RoadIcon({ className = "" }: { className?: string }) { + return ( + + + + + + ); +} + +export function PirogueIcon({ className = "" }: { className?: string }) { + return ( + + + + + + + + ); +} + +export function HammockIcon({ className = "" }: { className?: string }) { + return ( + + + + + + + + + + ); +} + +export function CompassIcon({ className = "" }: { className?: string }) { + return ( + + + + + + + ); +} + +export function PalmIcon({ className = "" }: { className?: string }) { + return ( + + + + + + + + + ); +} + +export function WaveIcon({ className = "" }: { className?: string }) { + return ( + + + + + ); +} + +export function HeartHandIcon({ className = "" }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/components/landing/CESection.tsx b/src/components/landing/CESection.tsx new file mode 100644 index 0000000..136c8aa --- /dev/null +++ b/src/components/landing/CESection.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; + +/** + * Section dédiée aux Comités d'Entreprise (CE). Registre coop/solidaire, + * voix différente du reste de la home (qui parle au touriste aventurier). + */ +export function CESection() { + return ( +
+
+
+ + Pour comités d'entreprise + +

+ Les carbets dorment quand vous n'y êtes pas. +
+ Partageons-les. +

+

+ Karbé est conçu pour que les comités sociaux possédant déjà un carbet le réservent à + leurs membres certains week-ends, et l'ouvrent au public touriste le reste de + l'année. Sans commission sur le séjour : le paiement revient intégralement au CE. +

+
+ +
    + {[ + { k: "0 %", v: "de commission sur le séjour" }, + { k: "CE first", v: "vos membres réservent en priorité" }, + { k: "Public ouvert", v: "le reste des dates rentre dans le pot" }, + { k: "Sans paperasse", v: "Stripe encaisse et reverse direct" }, + ].map(({ k, v }) => ( +
  • +
    {k}
    +
    {v}
    +
  • + ))} +
  • + + En savoir plus pour votre CE + + +
  • +
+
+
+ ); +} diff --git a/src/components/landing/ExperiencesSection.tsx b/src/components/landing/ExperiencesSection.tsx new file mode 100644 index 0000000..6b2091e --- /dev/null +++ b/src/components/landing/ExperiencesSection.tsx @@ -0,0 +1,75 @@ +import { RoadIcon, PirogueIcon } from "@/components/illustrations/Icons"; + +/** + * Section « 2 expériences » — route+fleuve vs expédition fleuve. + * Reflète la distinction métier qu'on appliquera côté schema dans le plugin + * `access-type`. Ici on ne fait que l'éditorialiser pour la home. + */ +export function ExperiencesSection() { + return ( +
+
+
+ + Deux façons de vivre Karbé + +

+ Du bord du fleuve à l'expédition pirogue. +

+

+ Selon l'envie, on choisit le carbet qui se rejoint en voiture pour un week-end facile, + ou celui qu'on n'atteint qu'en pirogue, à plusieurs heures du dernier village. +

+
+ +
+ {/* Route + fleuve */} +
+
+ +
+

+ 🛣️ Route + fleuve +

+

+ Le carbet du week-end +

+

+ Accessible par la piste depuis Kourou, Saint-Laurent ou Régina. Garez la voiture, + prenez vos affaires et vous y êtes. Pour les familles, les couples qui veulent du calme + sans logistique, les CE qui réservent des séjours courts. +

+
    +
  • 1 à 3 nuits typiques
  • +
  • Voiture ou 4×4 selon la piste
  • +
  • Carbets équipés, baignade possible
  • +
+
+ + {/* Expédition fleuve */} +
+
+ +
+

+ 🛶 Expédition fleuve +

+

+ Le carbet qu'on mérite +

+

+ Aucune route n'y mène. On embarque en pirogue depuis un dégrad, parfois deux ou + trois heures de remontée. Pour ceux qui veulent vraiment dormir loin — singes hurleurs, + ciel sans halo, l'eau du fleuve à 5 mètres du hamac. +

+
    +
  • 2 nuits minimum recommandées
  • +
  • Pirogue avec passeur (loueur ou partenaire)
  • +
  • Saison sèche conseillée (juillet-novembre)
  • +
+
+
+
+
+ ); +} diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx new file mode 100644 index 0000000..cf907d8 --- /dev/null +++ b/src/components/landing/Footer.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { PalmIcon, WaveIcon } from "@/components/illustrations/Icons"; + +export function LandingFooter() { + const year = new Date().getFullYear(); + return ( +
+ + +
+
+
+ + Karbé +
+

+ Marketplace des carbets fluviaux de Guyane. Solidaire avec les CE locaux. Sans + commission sur le séjour. +

+
+ +
+

Découvrir

+
    +
  • Tous les carbets
  • +
  • Comment ça marche
  • +
  • À propos de Karbé
  • +
+
+ +
+

Proposer

+
    +
  • Devenir loueur
  • +
  • Pour comités d'entreprise
  • +
  • Espace membre
  • +
+
+ +
+

Légal

+
    +
  • CGV
  • +
  • Mentions légales
  • +
  • Confidentialité
  • +
+
+
+ +
+
+ © {year} Karbé — projet associatif numérique en Guyane. + karbe.cosmolan.fr +
+
+
+ ); +} diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx new file mode 100644 index 0000000..6ee1165 --- /dev/null +++ b/src/components/landing/HeroSection.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import { CarbetRiver } from "@/components/illustrations/CarbetRiver"; + +/** + * Hero plein écran. Plugin `landing-hero`. + * Pas de dépendance image externe — illustration vectorielle inline. + */ +export function HeroSection() { + return ( +
+ {/* fond illustration */} +
+ + {/* voile sombre pour lisibilité texte */} +
+
+
+ +
+ + + Marketplace solidaire — sans commission sur le séjour + + +

+ Le karbé qui dort +
+ vous attend. +

+ +

+ Louez un carbet le long du Maroni, de l'Approuague ou de l'Oyapock. Le hamac est tendu, + la pirogue glisse, le silence est vrai. Pour quelques nuits, le fleuve vous appartient. +

+ +
+ + Découvrir un carbet + + + Proposer le mien + +
+
+
+ ); +} diff --git a/src/components/landing/HowItWorksSection.tsx b/src/components/landing/HowItWorksSection.tsx new file mode 100644 index 0000000..59ef653 --- /dev/null +++ b/src/components/landing/HowItWorksSection.tsx @@ -0,0 +1,61 @@ +import { CompassIcon, HammockIcon, HeartHandIcon } from "@/components/illustrations/Icons"; + +/** + * Section « Comment ça marche » — 3 étapes côté voyageur. + */ +export function HowItWorksSection() { + const steps = [ + { + icon: CompassIcon, + step: "01", + title: "Choisissez le fleuve", + body: + "Maroni, Approuague, Comté, Oyapock — chaque fleuve a son ambiance, son embarquement, ses carbets. Filtrez selon votre niveau d'aventure.", + }, + { + icon: HammockIcon, + step: "02", + title: "Réservez le carbet", + body: + "Dates, capacité, durée de pirogue le cas échéant. Paiement sécurisé Stripe, reversé au loueur sans commission sur le séjour.", + }, + { + icon: HeartHandIcon, + step: "03", + title: "Dormez vrai", + body: + "Le loueur (ou son partenaire) vous récupère au dégrad si besoin. Vous récupérez les clés du karbé, tendez le hamac, écoutez. Plus rien à faire.", + }, + ]; + + return ( +
+
+
+ + Comment ça marche + +

+ Trois étapes pour s'échapper. +

+
+ +
    + {steps.map(({ icon: Icon, step, title, body }) => ( +
  1. +
    + + {step} +
    +

    {title}

    +

    {body}

    +
  2. + ))} +
+
+
+ ); +} diff --git a/src/components/landing/TestimonialsSection.tsx b/src/components/landing/TestimonialsSection.tsx new file mode 100644 index 0000000..5771d7e --- /dev/null +++ b/src/components/landing/TestimonialsSection.tsx @@ -0,0 +1,58 @@ +/** + * Section témoignages — 3 stubs avec noms/contextes plausibles Guyane. + * Les contenus sont éditorialisés par défaut, remplaçables via le plugin + * `content-pages` (Phase 4) qui fournira un store éditable depuis l'admin. + */ +export function TestimonialsSection() { + const items = [ + { + name: "Émilie · CE Hôpital Cayenne", + from: "Carbet sur le Comté", + quote: + "On a ouvert le karbé du CE aux touristes deux week-ends par mois. Les revenus financent l'entretien sans qu'on doive demander au directeur.", + }, + { + name: "Yann · loueur particulier", + from: "Carbet sur le Maroni", + quote: + "Mon karbé dormait six mois par an. Je l'ai mis sur Karbé, j'ai cinq résas depuis. Et zéro commission, ça change tout.", + }, + { + name: "Marina · touriste métropole", + from: "Karbé Awara, Approuague", + quote: + "Trois heures de pirogue, et soudain plus aucun bruit humain. J'ai dormi comme jamais. La photo du passeur en train de pêcher est ma photo de l'année.", + }, + ]; + + return ( +
+
+
+ + Pas de marketing + +

+ Ils nous l'ont dit comme ça. +

+
+ +
    + {items.map(({ name, from, quote }) => ( +
  • + +

    {quote}

    +
    +
    {name}
    +
    {from}
    +
    +
  • + ))} +
+
+
+ ); +} From abc3844af25323967e23f38625f7d0d5f422eac8 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:22:25 +0000 Subject: [PATCH 03/61] chore: sync package-lock.json (qs@6.15.2 missing) --- package-lock.json | 172 ++++++++++------------------------------------ 1 file changed, 38 insertions(+), 134 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d3a79a..f80d6c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "next-auth": "^5.0.0-beta.31", "pg": "^8.21.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "stripe": "^18.3.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1187,9 +1188,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1206,9 +1204,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1225,9 +1220,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1244,9 +1236,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1263,9 +1252,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1282,9 +1268,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1301,9 +1284,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1320,9 +1300,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1339,9 +1316,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1364,9 +1338,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1389,9 +1360,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1414,9 +1382,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1439,9 +1404,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1464,9 +1426,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1489,9 +1448,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1514,9 +1470,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1739,9 +1692,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1758,9 +1708,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1777,9 +1724,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1796,9 +1740,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2566,9 +2507,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2586,9 +2524,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2606,9 +2541,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2626,9 +2558,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2782,7 +2711,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3189,9 +3118,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3206,9 +3132,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3223,9 +3146,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3240,9 +3160,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3257,9 +3174,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3274,9 +3188,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3291,9 +3202,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3308,9 +3216,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3325,9 +3230,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3342,9 +3244,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3873,7 +3772,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3887,7 +3785,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4241,7 +4138,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4387,7 +4283,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4397,7 +4292,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4435,7 +4329,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5166,7 +5059,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5237,7 +5129,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5269,7 +5160,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5367,7 +5257,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5453,7 +5342,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5482,7 +5370,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6337,9 +6224,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6361,9 +6245,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6385,9 +6266,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6409,9 +6287,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6550,7 +6425,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6845,7 +6719,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7440,6 +7313,21 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7885,7 +7773,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7905,7 +7792,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7922,7 +7808,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7941,7 +7826,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8162,6 +8046,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz", + "integrity": "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", From a564373a0753e63b2d231eeb7402a68400367ae1 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:25:32 +0000 Subject: [PATCH 04/61] chore(docker): copier prisma/ avant npm ci + dans runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le postinstall hook `prisma generate` du package.json a besoin de prisma/schema.prisma pour s'exécuter. Sans ça, npm ci échoue dès l'étape deps. Ajoute aussi prisma/ dans l'image runner pour pouvoir exécuter `prisma migrate deploy` depuis l'app en prod. --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8b406e9..252de8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,10 @@ FROM node:20-alpine AS base WORKDIR /app FROM base AS deps +# Le postinstall de Prisma a besoin de prisma/schema.prisma pour `prisma generate`. +# On copie donc le dossier prisma avant `npm ci`, sinon le hook crashe. COPY package.json package-lock.json ./ +COPY prisma ./prisma RUN npm ci FROM base AS builder @@ -21,6 +24,8 @@ RUN addgroup -S nextjs && adduser -S nextjs -G nextjs COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static +# Prisma schema + migrations dispo dans l'image runner pour `prisma migrate deploy` +COPY --from=builder /app/prisma ./prisma USER nextjs EXPOSE 3000 From 26922329d4cf741704b5acacc3690549a173eb58 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:28:19 +0000 Subject: [PATCH 05/61] chore(docker): npx prisma generate dans builder stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le client Prisma est généré dans src/generated/prisma (cf. schema.prisma output). Le post-install npm de deps stage le génère mais on n'embarque que node_modules, pas le src/generated. Le builder doit donc régénérer explicitement avant npm run build. --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 252de8d..5e3652b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,10 @@ FROM base AS builder ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY . . +# Régénère le client Prisma dans src/generated/prisma (le post-install de l'étape +# deps l'a fait dans deps:/app/src/generated qu'on n'embarque pas). Sans cette +# ligne, `next build` ne trouve pas le type `prisma.plugin` et autres. +RUN npx prisma generate RUN npm run build FROM node:20-alpine AS runner From e433ebc4398369e2c063b5d9a85441132aa2afa2 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sat, 30 May 2026 23:30:12 +0000 Subject: [PATCH 06/61] 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 07/61] 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 08/61] =?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 09/61] 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 10/61] =?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 11/61] 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 12/61] =?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 13/61] 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 14/61] 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 ( +

+ + +