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

+ {titleLine1} +
+ {titleAccent}. +

+ +

+ {subtitle} +

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

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

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

Espace administrateur

-

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

-
+
+
+

Tableau de bord

+

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

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

+ Raccourcis fréquents +

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

Médias ({media.length})

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

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

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

Ajouter un média par URL

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

+ {carbet.title} + +

+

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

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

Identité

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