Compare commits

...
Sign in to create a new pull request.

123 commits

Author SHA1 Message Date
d24e3b4af7 feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (push) Successful in 2m39s
2026-06-02 09:38:38 +00:00
Ubuntu
9da58288dc feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (pull_request) Successful in 2m18s
Nouveau modèle `RentalItemMedia` parallèle de `Media` (carbet) :
- s3Key / s3Url / sortOrder / type (PHOTO|VIDEO), cascade sur RentalItem
- Migration `20260603100000_rental_item_media` appliquée

Endpoints upload dédiés (mêmes conventions que carbet) :
- POST /api/uploads/rental-presign + POST /api/uploads/rental-finalize
  → auth par canManageRentalProvider (admin OR provider manager)
  → s3Key préfixé `rental-items/<itemId>/`
  → finalize hydrate `RentalItem.imageUrl` avec la première PHOTO
  → générateur de variantes (320/800/1600 via sharp) en best-effort
- DELETE /api/rental-media/[id] + POST /api/rental-media/reorder
  → reorder rafraîchit imageUrl (cover = sortOrder 0)

`MediaUploader` rendu générique :
- Nouveau prop `scope: {kind: "carbet" | "rental-item", id}` ; conserve
  rétro-compat `carbetId` (deprecated)
- Endpoints + payload key (`carbetId` ↔ `itemId`) calculés via
  `endpointsFor()`. Aucun changement de comportement côté carbet.

UI branchée :
- /admin/rental-items/[id] : section « Photos & vidéos » au-dessus du
  form, alimentée par `item.media` chargé par `getRentalItemForAdmin`
- /espace-prestataire/items/[itemId] : idem, charge via `getHostItem`
- /materiel/[itemId] : nouveau `<ItemGallery />` (thumbs cliquables +
  support vidéo). Fallback : ancien `item.imageUrl` si pas de média
  dédié (compat seed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 09:34:09 +00:00
d42584cc4c fix(rental): no setState in effect for cart hydration
All checks were successful
CI / test (push) Successful in 2m15s
2026-06-02 08:54:59 +00:00
Ubuntu
15f41a7e2a fix(rental): no setState in effect for cart hydration
All checks were successful
CI / test (pull_request) Successful in 2m21s
ESLint react-hooks/set-state-in-effect bloque le CI. On déplace la
re-hydratation depuis le cookie dans le lazy initializer de useState
(qui ne court qu'une fois côté client). Conserve la cohérence si un
autre onglet a modifié le panier entre le render serveur et l'hydration,
sans déclencher de re-render en cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:54:39 +00:00
740e9958aa feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (push) Failing after 1m10s
2026-06-02 08:50:15 +00:00
Ubuntu
5607a51980 feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (pull_request) Failing after 1m10s
3 nouveaux templates email (best-effort, dry-run sans Resend) :
- sendRentalRequestedTenant : récap de demande au locataire (par RB)
- sendRentalRequestedProvider : nouvelle demande au prestataire
- sendRentalConfirmed : confirmation paiement reçu

Branchements :
- POST /api/rentals/checkout : envoie tenant + provider après création
  des RentalBooking (PENDING), catch global pour ne pas bloquer
- Webhook Stripe rental-bundle : envoie sendRentalConfirmed à chaque
  locataire après update CONFIRMED+SUCCEEDED

Plugin gear-rental :
- Ajout au registry (catégorie business)
- layout.tsx /materiel + /espace-prestataire avec requirePluginOr404
- requirePluginOr404 dans /panier et /mes-locations
- isPluginEnabled guard dans POST /api/rentals/checkout (404 si off)
- SiteHeader masque liens Matériel / Mes locations / Espace prestataire
  + CartBadge si plugin désactivé
- CompleteYourStay renvoie null si plugin désactivé
Décision admin → activable depuis /admin/plugins comme tous les autres.

Tests vitest (tests/lib/rentals.test.ts, 16 tests) :
- diffDays (mêmes dates, 1 nuit, 7 jours, négatif)
- parseCart (null/garbage/schéma invalide/valide/format date)
- serializeCart (updatedAt, roundtrip)
- commission formula (0%, 15%, arrondi centime)
- availability arithmetic (totalQty libre, soustractions, plancher 0)

53 tests pass total. Build OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:49:39 +00:00
0723e50189 feat(rental): Sprint D — panier + checkout + carbet integration
Some checks failed
CI / test (push) Failing after 1m9s
2026-06-02 08:44:26 +00:00
Ubuntu
91b4d918ea feat(rental): Sprint D — panier + checkout + intégration carbet
Some checks failed
CI / test (pull_request) Failing after 1m8s
Cart lib + cookie persistence (karbe-rental-cart, 30j) avec context React
useCart(). Provider wrappé dans layout pour hydratation server→client.

Page /panier :
- Récap regroupé par prestataire (sous-totaux, caution)
- Édition lignes (dates, qté), suppression, vider panier
- Bouton « Valider et payer » → POST /api/rentals/checkout
- Badge 🛒 dans SiteHeader avec total items

Composant <AddToCart /> sur /materiel/[itemId] avec date picker + qté.

API POST /api/rentals/checkout :
- Validation auth + items actifs + provider approved + qté/dates
- Transaction Prisma : recheck stock par fenêtre + crée 1 RentalBooking
  par prestataire + RentalLines (snapshot prix) + RentalItemAvailability
  (blocage des dispos)
- Calcul commissionAmount selon provider.commissionPct
- Si Stripe activé : Checkout Session unique avec 1 line_item par
  RentalBooking, metadata {type:"rental-bundle", rentalBookingIds:[]}
- Sinon : crée en PENDING, retourne rentalBookingIds
- Vide le cookie panier après création
- Audit log rental.checkout.created

Webhook Stripe étendu :
- checkout.session.completed type=rental-bundle → CONFIRMED+SUCCEEDED
  sur toutes les RentalBookings du bundle
- payment_intent.payment_failed metadata.rentalBookingIds → CANCELLED
  + supprime les RentalItemAvailability (libère le stock)

Intégration carbet :
- /carbets/[slug] : panneau « Compléter votre séjour » avec items des
  prestataires de la même rivière + System D (recommandation contextuelle)
- /reservations/[id] : section « Matériel associé » listant les
  RentalBookings liées
- /mes-locations : page récap toutes les locations (System D + tiers,
  liées carbet ou standalone)
- Lien « Mes locations » dans SiteHeader

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:41:53 +00:00
1165f32a63 Merge pull request 'feat(rental): Sprint C — espace prestataire' (#76) from feat/rental-sprint-c into main
All checks were successful
CI / test (push) Successful in 2m18s
2026-06-02 08:01:44 +00:00
Claude Integration
59786e5365 feat(rental): Sprint C — espace prestataire (signup+dashboard+items+calendrier+résa)
All checks were successful
CI / test (pull_request) Successful in 2m33s
2026-06-02 08:01:42 +00:00
8d7e9cfdc2 Merge pull request 'feat(rental): Sprint B — catalogue public' (#75) from feat/rental-sprint-b into main
All checks were successful
CI / test (push) Successful in 2m14s
2026-06-02 07:49:46 +00:00
Claude Integration
f31fb8a32c feat(rental): Sprint B — catalogue public /materiel + détail item + dispo + nav
All checks were successful
CI / test (pull_request) Successful in 2m28s
2026-06-02 07:49:43 +00:00
1dd2d65626 Merge pull request 'fix(rental): client/server import boundary' (#74) from fix/rental-client-server-boundary into main
All checks were successful
CI / test (push) Successful in 2m11s
2026-06-02 03:31:26 +00:00
Claude Integration
90cc7a94af fix(rental): extract category labels en fichier neutre (importable client)
All checks were successful
CI / test (pull_request) Successful in 2m9s
2026-06-02 03:31:22 +00:00
46d3c2d3ab Merge pull request 'feat(rental): Sprint A — modèle + admin + seed' (#73) from feat/rental-sprint-a into main
Some checks failed
CI / test (push) Failing after 1m49s
2026-06-02 03:26:07 +00:00
Claude Integration
e2f3f070fa feat(rental): Sprint A — modèle Prisma + admin CRUD + seed 13 items
Some checks failed
CI / test (pull_request) Failing after 1m52s
2026-06-02 03:26:04 +00:00
d2dcc698e9 Merge pull request 'feat(forms): critères opérationnels dans les formulaires' (#72) from feat/operational-criteria-forms into main
All checks were successful
CI / test (push) Successful in 2m5s
2026-06-02 02:46:36 +00:00
Claude Integration
4901bb950e feat(forms): 4 critères opérationnels dans formulaires admin + espace hôte
All checks were successful
CI / test (pull_request) Successful in 2m16s
2026-06-02 02:46:34 +00:00
1f8250ad7e Merge pull request 'feat: critères opérationnels Guyane' (#71) from feat/operational-criteria into main
All checks were successful
CI / test (push) Successful in 2m5s
2026-06-02 02:26:04 +00:00
Claude Integration
dc2b07507f feat: 4 critères opérationnels (route/capacité/électricité/GSM) + presets profils + badges
All checks were successful
CI / test (pull_request) Successful in 2m23s
2026-06-02 02:26:02 +00:00
153d0671c0 Merge pull request 'feat(reels): swipe horizontal animé' (#70) from feat/reels-swipe-animation into main
All checks were successful
CI / test (push) Successful in 2m4s
2026-06-02 02:03:25 +00:00
Claude Integration
d5732917e3 feat(reels): swipe horizontal animé avec suivi du doigt + snap
All checks were successful
CI / test (pull_request) Successful in 2m16s
2026-06-02 02:03:23 +00:00
5449ec9047 Merge pull request 'feat: PWA installable' (#69) from feat/pwa into main
All checks were successful
CI / test (push) Successful in 2m3s
2026-06-02 01:53:24 +00:00
Claude Integration
bc158ca144 feat(pwa): manifest + icônes 192/512/maskable + Apple touch + viewport theme-color
All checks were successful
CI / test (pull_request) Successful in 2m15s
2026-06-02 01:53:22 +00:00
b8b421e839 Merge pull request 'feat(cron): regenerate-variants' (#68) from feat/cron-regenerate-variants into main
All checks were successful
CI / test (push) Successful in 2m11s
2026-06-02 01:27:22 +00:00
Claude Integration
4fb7c948ad feat(cron): regenerate-variants task pour batch tous les Media existants
All checks were successful
CI / test (pull_request) Successful in 2m24s
2026-06-02 01:27:20 +00:00
3a7c325373 Merge pull request 'feat: variantes responsives image' (#67) from feat/responsive-variants into main
All checks were successful
CI / test (push) Successful in 2m9s
2026-06-02 01:05:27 +00:00
Claude Integration
e2d3b6a686 feat: variantes responsives 320/800/1600 via sharp + srcset partout (Reels, cards, galerie, favoris)
All checks were successful
CI / test (pull_request) Successful in 2m21s
2026-06-02 01:05:25 +00:00
e542a853fa Merge pull request 'feat: Reels plein écran + admin uploader' (#66) from feat/reels-mobile-polish-and-admin-uploader into main
All checks were successful
CI / test (push) Successful in 1m57s
2026-06-02 00:52:59 +00:00
Claude Integration
701a1f02bd feat: Reels plein écran mobile + MediaUploader dans l'admin
All checks were successful
CI / test (pull_request) Successful in 2m19s
2026-06-02 00:52:57 +00:00
403e21fe0a Merge pull request 'feat: Au fil de l'eau (Reels) + uploader pro + favoris' (#65) from feat/au-fil-de-leau into main
All checks were successful
CI / test (push) Successful in 2m7s
2026-06-02 00:27:18 +00:00
Claude Integration
2545a5e1a8 feat: « Au fil de l'eau » — Reels mobile + uploader pro + favoris
All checks were successful
CI / test (pull_request) Successful in 2m18s
2026-06-02 00:27:16 +00:00
a575d40163 Merge pull request 'feat: BookingForm → Stripe Checkout' (#64) from feat/wire-stripe-checkout into main
All checks were successful
CI / test (push) Successful in 1m57s
2026-06-01 23:35:33 +00:00
Claude Integration
2914e5605a feat: BookingForm bascule sur Stripe Checkout quand STRIPE_SECRET_KEY est posée
All checks were successful
CI / test (pull_request) Successful in 2m10s
2026-06-01 23:35:30 +00:00
8285909178 Merge pull request 'feat: carte catalogue + À propos' (#63) from feat/catalog-map-and-about into main
All checks were successful
CI / test (push) Successful in 1m59s
2026-06-01 23:27:59 +00:00
Claude Integration
71dd8c1dad feat: carte interactive du catalogue + refonte page À propos (2.2-2.6k caractères)
All checks were successful
CI / test (pull_request) Successful in 2m21s
2026-06-01 23:27:57 +00:00
444fd1e6fd Merge pull request 'fix(backup): mc image entrypoint' (#62) from fix/backup-mc-entrypoint into main
All checks were successful
CI / test (push) Successful in 1m56s
2026-06-01 20:21:42 +00:00
Claude Integration
92deffa109 fix(backup): minio/mc a entrypoint=mc, ajouter --entrypoint /bin/sh pour wrapper
All checks were successful
CI / test (pull_request) Successful in 1m58s
2026-06-01 20:21:40 +00:00
cf9ee2bd1e Merge pull request 'feat(hardening): rate limit + cron + backup' (#61) from feat/production-hardening into main
All checks were successful
CI / test (push) Successful in 2m7s
2026-06-01 20:16:59 +00:00
Claude Integration
a373bd60ad feat(hardening): rate limit (signup/reset/bookings) + tâches cron + backup PostgreSQL nocturne
All checks were successful
CI / test (pull_request) Successful in 2m10s
2026-06-01 20:16:57 +00:00
f1fb06b0af Merge pull request 'fix: rebrancher /espace-hote sur le dashboard' (#60) from fix/host-dashboard-page into main
All checks were successful
CI / test (push) Successful in 2m7s
2026-06-01 16:20:08 +00:00
Claude Integration
55c0244336 fix: rebrancher espace-hote/page.tsx sur le nouveau dashboard (oubli PR#59)
All checks were successful
CI / test (pull_request) Successful in 2m24s
2026-06-01 16:20:06 +00:00
d1a1bb04de Merge pull request 'feat: espace hôte dashboard + lightbox galerie' (#59) from feat/host-dashboard-and-lightbox into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-01 16:16:27 +00:00
Claude Integration
1e6acf29b9 feat: dashboard espace hôte (KPIs + résa pending + carbets + activité) + lightbox galerie
All checks were successful
CI / test (pull_request) Successful in 2m42s
2026-06-01 16:16:25 +00:00
3e109fb7b4 Merge pull request 'fix: facettes search effectives' (#58) from fix/search-facets into main
All checks were successful
CI / test (push) Successful in 2m0s
2026-06-01 10:21:06 +00:00
Claude Integration
a58815ec9c fix: ajout effectif facettes priceMax + amenities dans SearchFilters (oubli PR#57)
All checks were successful
CI / test (pull_request) Successful in 2m10s
2026-06-01 10:21:03 +00:00
61ccb05c75 Merge pull request 'feat: reset password + mon-compte + facettes recherche' (#57) from feat/reset-profile-facets into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-01 10:16:39 +00:00
Claude Integration
a6df96db7e feat: reset password + page mon-compte (RGPD) + facettes recherche (prix max, équipements)
All checks were successful
CI / test (pull_request) Successful in 2m19s
2026-06-01 10:16:37 +00:00
0b5e5408e8 Merge pull request 'feat: calendrier visuel + carte Leaflet' (#56) from feat/visual-calendar-and-map into main
All checks were successful
CI / test (push) Successful in 1m53s
2026-06-01 05:27:35 +00:00
Claude Integration
31aa7a4865 feat: calendrier visuel mensuel + carte Leaflet sur fiche carbet
All checks were successful
CI / test (pull_request) Successful in 2m0s
2026-06-01 05:27:33 +00:00
231416dd08 Merge pull request 'feat: SiteHeader global' (#55) from feat/site-header into main
All checks were successful
CI / test (push) Successful in 1m58s
2026-06-01 04:29:53 +00:00
Claude Integration
3bc52b2b60 feat: global SiteHeader avec user menu (login/inscription, Mes réservations, Espace hôte, Admin)
All checks were successful
CI / test (pull_request) Successful in 2m9s
2026-06-01 04:29:52 +00:00
4e8b88ab34 Merge pull request 'fix(ci): lint errors qui bloquaient le runner' (#54) from fix/ci-lint-errors into main
All checks were successful
CI / test (push) Successful in 1m58s
2026-06-01 04:18:51 +00:00
Claude Integration
6eed6bffc8 fix(ci): 5 erreurs ESLint Next 16 (Date.now impure, <a> vers /admin, setState dans effect)
All checks were successful
CI / test (pull_request) Successful in 2m0s
2026-06-01 04:18:49 +00:00
ccaad1d546 Merge pull request 'feat(p2): tests + health + metrics + CI' (#53) from feat/p2-tests-health-ci into main
Some checks failed
CI / test (push) Failing after 1m2s
2026-06-01 02:27:16 +00:00
Claude Integration
14fd9a5940 feat(p2): vitest + 27 tests + /api/health enrichi + /api/metrics + workflow CI
Some checks failed
CI / test (pull_request) Failing after 2m13s
2026-06-01 02:27:14 +00:00
56e5c48a84 Merge pull request 'feat(p1): calendar + emails' (#52) from feat/p1-calendar-legal-emails into main 2026-06-01 02:20:40 +00:00
Claude Integration
b59b8a0af2 feat(p1): calendrier dispo + emails Resend + amount calculé + best-effort welcome/confirmation/refund 2026-06-01 02:20:38 +00:00
4e14854245 Merge pull request 'feat(p0): pricing + booking + signup' (#51) from feat/p0-pricing-booking-signup into main 2026-06-01 01:34:03 +00:00
Claude Integration
e79b6dd141 feat(p0): prix/nuit + booking form public + /inscription + /reservations/[id] 2026-06-01 01:34:00 +00:00
f09a680059 Merge pull request 'feat(admin): /admin/home — éditeur page d'accueil' (#50) from feat/admin-home-editor into main 2026-06-01 01:10:51 +00:00
Claude Integration
a9fcd18022 feat(admin): /admin/home — éditeur des textes de la page d'accueil (FR+EN, override DB) 2026-06-01 01:10:49 +00:00
d3cc5bdfb9 Merge pull request 'fix(admin): PATCH content-pages respecte ?lang=' (#49) from fix/admin-content-pages-patch-lang into main 2026-06-01 00:51:21 +00:00
Claude Integration
1f8dd90979 fix(admin): PATCH content-pages respecte ?lang= (sinon écrasait FR) 2026-06-01 00:51:19 +00:00
0244eb5029 Merge pull request 'fix(admin): content-pages multilang' (#48) from fix/admin-content-pages-multilang into main 2026-06-01 00:49:32 +00:00
Claude Integration
a5ae692cf4 fix(admin): content-pages éditait FR quel que soit le lien cliqué — support multilang complet 2026-06-01 00:49:31 +00:00
c8c97e467d Merge pull request 'feat(admin): Sprint 6 — Polish' (#47) from feat/admin-sprint6-polish into main 2026-06-01 00:44:41 +00:00
Claude Integration
4e6867b365 feat(admin): Sprint 6 — /admin/media gallery + theme write-through 2026-06-01 00:44:39 +00:00
f9c10f151c Merge pull request 'feat(admin): Sprint 5 — Gouvernance' (#46) from feat/admin-sprint5-gouvernance into main 2026-06-01 00:13:51 +00:00
Claude Integration
79ddcd23f5 feat(admin): Sprint 5 — Audit log + Settings (gouvernance) 2026-06-01 00:13:49 +00:00
2ad4cbed80 Merge pull request 'feat(admin): Sprint 4 — Écosystème' (#45) from feat/admin-sprint4-ecosysteme into main 2026-05-31 21:36:24 +00:00
Claude Integration
99f3bbdc71 feat(admin): Sprint 4 — Organisations CE + Prestataires pirogue (CRUD) 2026-05-31 21:36:22 +00:00
19b4ff8293 Merge pull request 'feat(admin): Sprint 3 — Activity' (#44) from feat/admin-sprint3-activity into main 2026-05-31 21:20:48 +00:00
Claude Integration
d9ee072744 feat(admin): Sprint 3 — Réservations, Utilisateurs, Avis 2026-05-31 21:20:46 +00:00
8f31047b36 Merge pull request 'chore: prisma type + cleanup' (#43) from chore/admin-carbets-prisma-where into main 2026-05-31 21:08:37 +00:00
Claude Integration
fea55a7ddb chore(admin): Prisma.CarbetWhereInput type + cleanup options orphelines 2026-05-31 21:08:35 +00:00
00a5533bea Merge pull request 'chore: split options client/server' (#42) from chore/admin-carbet-options-split into main 2026-05-31 21:06:49 +00:00
Claude Integration
fc01144e0e chore(admin): split options enum dans fichier neutre
Le client component CarbetForm importait des options depuis lib/admin/carbets
qui contient "server-only" → erreur build turbopack. Sortie des options dans
src/lib/admin/carbet-options.ts sans server-only.
2026-05-31 21:06:47 +00:00
820f7a821b Merge pull request 'feat(admin): CRUD carbets + médias (Sprint 2)' (#41) from feat/admin-carbets-crud into main 2026-05-31 21:04:56 +00:00
Claude Integration
9aa0771001 feat(admin): CRUD complet carbets + gestion médias (Sprint 2)
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.
2026-05-31 19:51:33 +00:00
3ec7a3ff10 Merge pull request 'feat(admin): shell + dashboard + ⌘K (Sprint 1)' (#40) from feat/admin-shell-foundation into main 2026-05-31 18:22:08 +00:00
Claude Integration
bcb93c6b29 feat(admin): shell admin + dashboard KPI + recherche ⌘K (Sprint 1)
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.
2026-05-31 18:21:50 +00:00
ffb39a3bf5 Merge pull request 'feat(plugin): aquarelle seed media + upload script' (#39) from feat/aquarelle-seed-media into main 2026-05-31 12:20:56 +00:00
Claude Integration
47258bf1be feat(plugin): image-gallery-aquarelle-seed hook + upload script
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.
2026-05-31 12:20:35 +00:00
93aebc4e87 Merge pull request 'feat(plugin): theme-aquarelle + hero' (#38) from feat/theme-aquarelle into main 2026-05-31 12:15:30 +00:00
Claude Integration
c69c355f90 feat(plugin): theme-aquarelle + hero variant (Phase 2.4 partie 1/2)
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).
2026-05-31 12:15:07 +00:00
bb2fee7659 Merge pull request 'chore(admin): findUnique composite key' (#37) from chore/admin-content-composite-key into main 2026-05-31 11:48:16 +00:00
Claude Integration
8196a1a3f9 chore(admin): adapter findUnique/update à la PK composite (slug, lang)
Admin édite la version FR par défaut. Édition multi-langues = future feature.
2026-05-31 11:48:14 +00:00
df9eb5fcbd Merge pull request 'feat: pages contenu bilingues' (#36) from feat/i18n-content-pages into main 2026-05-31 11:45:49 +00:00
Claude Integration
87c3e7a581 feat: ContentPage bilingue (PK composite slug+lang) + seed pages EN
Migration : ContentPage.id devient PK composite (slug, lang) au lieu de slug
seul, pour stocker une version FR et une version EN du même slug. Index sur
slug seul pour les lookups.

Schema Prisma : @@id([slug, lang]).

Helpers :
- getContentPage(slug, lang) avec fallback FR si la version dans la langue
  demandée n'existe pas
- listContentPages(category?, lang?) accepte un filtre lang
- upsertContentPage : utilise le composite key

Pages publiques (a-propos, faq, comment-ca-marche, pour-comites-entreprise,
devenir-loueur, cgv, mentions-legales, politique-de-confidentialite) :
ajoutent un appel à getLocale() et le passent à getContentPage.

Seeds :
- src/lib/plugins/seeds/content-pages-en.ts : 8 pages traduites en anglais
- hook onEnable du plugin i18n-fr-en : seed EN pages au toggle on. Désactiver
  i18n n'efface pas les EN pages (elles dorment, fallback FR reprend).

Résultat : quand l'utilisateur switche vers EN, /a-propos, /faq, /cgv, etc.
basculent en anglais. Le contenu hors-DB (composants UI) bascule déjà via les
dictionnaires de la PR i18n-fr-en initiale.
2026-05-31 11:45:47 +00:00
88a937f2fd Merge pull request 'feat(plugin): i18n FR + EN' (#35) from feat/i18n-fr-en into main 2026-05-31 11:38:41 +00:00
Claude Integration
cf9da94bb5 feat(plugin): i18n FR + EN (Phase 4.2)
Infrastructure i18n légère, sans deps externe :

- lib/i18n/types.ts : LOCALES, DEFAULT_LOCALE, cookie name
- lib/i18n/server.ts : getLocale (cookie > Accept-Language > FR),
  t(key) async server-side, dict(locale)
- lib/i18n/client.tsx : LocaleProvider + useLocale + useT
- messages/fr.json + messages/en.json : ~50 clés pour landing + header + footer
- LocaleSwitcher component (cookie + router.refresh)

Plugin gated :
- Quand i18n-fr-en désactivé, getLocale() force FR. Le switcher ne s'affiche
  pas dans le hero. Pas d'impact sur le rendu existant.
- Quand activé, switcher visible coin haut-droit du hero. Les composants
  landing/header/footer rendent en FR ou EN selon le cookie utilisateur.

Composants i18n-isés :
- HeroSection (eyebrow, titre, CTA)
- ExperiencesSection (route/fleuve vs expédition, tous les bullets)
- HowItWorksSection (3 étapes)
- CESection (KPIs + body + CTA)
- TestimonialsSection (eyebrow + titre, citations restent en VO)
- Footer (taglines, colonnes)
- SeasonBanner (3 saisons + messages)
- AccessTypeBadge (labels + tooltips)

Pour les ContentPage, le champ lang existait déjà. Une suite (PR ultérieure)
ajoutera le filtre lang dans getContentPage + seed pages EN.
2026-05-31 11:38:39 +00:00
efeea16467 Merge pull request 'feat(plugin): pirogue-providers' (#34) from feat/pirogue-providers into main 2026-05-31 11:29:32 +00:00
Claude Integration
a174f99eba feat(plugin): pirogue-providers (Phase 3.3)
Modèle PirogueProvider (id, name, contacts, fleuves, tarif, description)
+ enum TransportMode (OWNER_PROVIDES, SELF_ARRANGE, PARTNER_PROVIDER) sur Carbet
+ relation Carbet → PirogueProvider (nullable, ondelete:SetNull)

Composants :
- PirogueTransportBlock (server, gated par plugin) sur fiche carbet :
  affiche le mode + provider partenaire avec contacts/tarif/description
- Page publique /partenaires-pirogue : liste des partenaires actifs

Seed onEnable :
- 3 partenaires démo (Pirogues du Maroni, Approuague Aventures, Oyapock Frontière)
  avec tarifs estimatifs et fleuves desservis réels
- Attribution aux 6 carbets démo :
  · Awara (Maroni), Maripa (Approuague), Paripou (Oyapock) → PARTNER_PROVIDER
  · Wapa (Comté), Mahury CE → OWNER_PROVIDES
  · Kourou Couleuvre → SELF_ARRANGE

onDisable désactive les partenaires démo et détache les carbets démo.
2026-05-31 11:29:29 +00:00
8c0b849ad7 Merge pull request 'feat(plugins): content-pages + legal-pages' (#33) from feat/content-pages-and-legal into main 2026-05-31 10:12:28 +00:00
Claude Integration
68f37f554f feat(plugins): content-pages + legal-pages (Phase 4.1 + 4.3)
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)
2026-05-31 10:12:13 +00:00
ae8f79b436 Merge pull request 'chore: wire StayConstraints' (#32) from chore/wire-stay-constraints into main 2026-05-31 08:59:48 +00:00
Claude Integration
a7761ca323 chore: wire StayConstraints + minStayNights dans carbet-card + search (oubli PR#30) 2026-05-31 08:59:46 +00:00
fdf66bfc74 Merge pull request 'chore(prisma): champs Carbet min-stay + seasonality' (#31) from chore/schema-min-stay-seasonality into main 2026-05-31 08:52:48 +00:00
Claude Integration
3405f00476 chore(prisma): ajoute minStayNights/maxStayNights/minCapacity/seasonalConstraints au modèle Carbet (oubli PR#30) 2026-05-31 08:52:46 +00:00
32410c95c7 Merge pull request 'feat(plugins): seasonality + min-stay' (#30) from feat/seasonality-and-min-stay into main 2026-05-31 08:50:28 +00:00
Claude Integration
be2391998d feat(plugins): seasonality + min-stay (Phase 3.2 + 3.4)
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 <SeasonBanner /> 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 <StayConstraints /> 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é.
2026-05-31 08:50:26 +00:00
4842a44746 Merge pull request 'chore(prisma): enum AccessType' (#29) from chore/access-type-enum-v2 into main 2026-05-31 03:00:54 +00:00
Claude Integration
bc571b38d1 chore(prisma): déclare enum AccessType (oublié dans PR#27) 2026-05-31 03:00:52 +00:00
35080dcde1 Merge pull request 'feat(plugins): access-type + demo-carbets-seed' (#27) from feat/access-type-and-demo-carbets into main 2026-05-31 02:56:41 +00:00
Claude Integration
5e59202505 feat(plugins): access-type + demo-carbets-seed (Phase 3.1 + 2.5)
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 <AccessTypeBadge> 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)
2026-05-31 02:56:25 +00:00
9b9963403d Merge pull request 'chore(layout): force-dynamic' (#26) from chore/layout-dynamic into main 2026-05-30 23:36:44 +00:00
Claude Integration
049d0bb423 chore(layout): force-dynamic pour refléter l'état des plugins en live
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.
2026-05-30 23:36:42 +00:00
de9f73246b Merge pull request 'chore(sitemap): force dynamic' (#25) from chore/sitemap-runtime into main 2026-05-30 23:32:09 +00:00
Claude Integration
b1c2877e43 chore(sitemap): force dynamic + try/catch DB
Évite que le build échoue quand la DB n'est pas joignable au prerender.
2026-05-30 23:32:07 +00:00
dc05fe118b Merge pull request 'chore(plugins): cast config en Prisma.InputJsonValue' (#24) from chore/plugin-json-type into main 2026-05-30 23:30:25 +00:00
Claude Integration
e433ebc439 chore(plugins): cast config en Prisma.InputJsonValue
Le type Record<string,unknown> ne satisfait pas le narrowing JSON Prisma.
Cast explicite pour faire passer le build TS.
2026-05-30 23:30:12 +00:00
1868b36379 Merge pull request 'chore(docker): npx prisma generate dans builder' (#23) from chore/dockerfile-prisma-generate-builder into main
Merge PR#23
2026-05-30 23:28:30 +00:00
Claude Integration
26922329d4 chore(docker): npx prisma generate dans builder stage
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.
2026-05-30 23:28:19 +00:00
d3ce396b20 Merge pull request 'chore(docker): copier prisma/ avant npm ci' (#22) from chore/dockerfile-prisma-copy into main
Merge PR#22: fix Dockerfile prisma copy
2026-05-30 23:25:44 +00:00
Claude Integration
a564373a07 chore(docker): copier prisma/ avant npm ci + dans runner
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.
2026-05-30 23:25:32 +00:00
800a06afc6 Merge pull request 'chore: sync package-lock.json' (#21) from chore/lockfile-sync into main
Merge PR#21: sync lockfile
2026-05-30 23:22:38 +00:00
Claude Integration
abc3844af2 chore: sync package-lock.json (qs@6.15.2 missing) 2026-05-30 23:22:25 +00:00
b4617545d0 Merge pull request 'feat(plugins): visuels Phase 2 (theme-guyane, landing-hero, landing-sections)' (#19) from feat/plugins-visuals-phase2 into main
Merge PR#19: plugins visuels Phase 2
2026-05-30 23:19:42 +00:00
Claude Integration
d19701e275 feat(plugins-visuels): theme-guyane + landing-hero + landing-sections
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.
2026-05-30 23:19:24 +00:00
4454f7331d Merge pull request 'feat(plugins): foundation système Plugin Karbé' (#18) from feat/plugin-foundation into main
Merge PR#18: plugin foundation
2026-05-30 22:17:27 +00:00
Claude Integration
62cc464738 feat(plugins): foundation système Plugin Karbé
- 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 <IfPluginEnabled plugin=... fallback=...>
- 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.
2026-05-30 22:17:10 +00:00
d7de43a70e Merge pull request 'SYS-19: Compléter docker-compose.prod.yml avec Postgres et MinIO co-déployés' (#17) from feat/sys-19-prod-compose-postgres-minio into main
Merge PR#17: postgres + minio co-déployés (SYS-19)
2026-05-30 18:39:12 +00:00
281 changed files with 26618 additions and 326 deletions

59
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,59 @@
name: CI
# Lance lint + typecheck + tests + build sur push/PR.
#
# Workflow dormant tant qu'aucun runner Forgejo n'est enregistré.
# Pour activer :
# 1) Sur git.cosmolan.fr, générer un token runner :
# Admin → Actions → Runners → Create new Runner Token
# (ou pour ce repo seul : Settings → Actions → Runners → Create)
# 2) Sur la machine d'exécution :
# wget https://codeberg.org/forgejo/runner/releases/download/v6.7.0/forgejo-runner-6.7.0-linux-amd64
# chmod +x forgejo-runner-6.7.0-linux-amd64
# ./forgejo-runner-6.7.0-linux-amd64 register \
# --instance https://git.cosmolan.fr \
# --token <TOKEN> \
# --name karbe-ci \
# --labels "ubuntu-latest:docker://node:20"
# 3) Démarrer :
# ./forgejo-runner-6.7.0-linux-amd64 daemon
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Generate Prisma client
run: npx prisma generate
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build (smoke)
run: npm run build
env:
# Stubs nécessaires au build statique — pas de connexion réelle.
DATABASE_URL: "postgresql://stub:stub@localhost:5432/stub?schema=public"
NEXTAUTH_SECRET: "ci-secret-not-for-production"
AUTH_SECRET: "ci-secret-not-for-production"
NEXT_PUBLIC_SITE_URL: "https://example.invalid"

View file

@ -2,13 +2,20 @@ FROM node:20-alpine AS base
WORKDIR /app WORKDIR /app
FROM base AS deps 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 package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci RUN npm ci
FROM base AS builder FROM base AS builder
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . 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 RUN npm run build
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
@ -21,6 +28,8 @@ RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static 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 USER nextjs
EXPOSE 3000 EXPOSE 3000

2628
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,18 +7,30 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"postinstall": "prisma generate" "postinstall": "prisma generate",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1056.0", "@aws-sdk/client-s3": "^3.1056.0",
"@aws-sdk/s3-request-presigner": "^3.1058.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"leaflet": "^1.9.4",
"next": "16.2.6", "next": "16.2.6",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0", "pg": "^8.21.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"resend": "^4.8.0",
"sharp": "^0.34.5",
"stripe": "^18.3.0" "stripe": "^18.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -26,11 +38,13 @@
"@types/node": "^20.19.41", "@types/node": "^20.19.41",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.2.6", "eslint-config-next": "^16.2.6",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^3.2.4"
} }
} }

View file

@ -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");

View file

@ -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");

View file

@ -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;

View file

@ -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");

View file

@ -0,0 +1,29 @@
-- Plugin pirogue-providers : modèle PirogueProvider + transportMode sur Carbet
CREATE TYPE "TransportMode" AS ENUM ('OWNER_PROVIDES', 'SELF_ARRANGE', 'PARTNER_PROVIDER');
CREATE TABLE "PirogueProvider" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"contactEmail" TEXT,
"contactPhone" TEXT,
"rivers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"pricingNote" TEXT,
"description" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL
);
CREATE INDEX "PirogueProvider_active_idx" ON "PirogueProvider" ("active");
ALTER TABLE "Carbet"
ADD COLUMN "transportMode" "TransportMode",
ADD COLUMN "pirogueProviderId" TEXT;
ALTER TABLE "Carbet"
ADD CONSTRAINT "Carbet_pirogueProviderId_fkey"
FOREIGN KEY ("pirogueProviderId") REFERENCES "PirogueProvider"("id")
ON DELETE SET NULL;
CREATE INDEX "Carbet_pirogueProviderId_idx" ON "Carbet" ("pirogueProviderId");

View file

@ -0,0 +1,8 @@
-- Plugin i18n-fr-en + content-pages :
-- ContentPage devient bilingue → PK composite (slug, lang)
-- pour pouvoir stocker une version FR et une version EN du même slug.
ALTER TABLE "ContentPage" DROP CONSTRAINT "ContentPage_pkey";
ALTER TABLE "ContentPage" ADD CONSTRAINT "ContentPage_pkey" PRIMARY KEY ("slug", "lang");
CREATE INDEX IF NOT EXISTS "ContentPage_slug_idx" ON "ContentPage" ("slug");

View file

@ -0,0 +1,22 @@
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"event" TEXT NOT NULL,
"target" TEXT,
"actorEmail" TEXT,
"details" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AuditLog_scope_idx" ON "AuditLog"("scope");
CREATE INDEX "AuditLog_event_idx" ON "AuditLog"("event");
CREATE INDEX "AuditLog_actorEmail_idx" ON "AuditLog"("actorEmail");
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
CREATE TABLE "Setting" (
"key" TEXT NOT NULL,
"value" JSONB NOT NULL DEFAULT '{}',
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
);

View file

@ -0,0 +1,9 @@
CREATE TABLE "PasswordResetToken" (
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash")
);
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");

View file

@ -0,0 +1,9 @@
CREATE TABLE "Translation" (
"key" TEXT NOT NULL,
"lang" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT,
CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang")
);
CREATE INDEX "Translation_lang_idx" ON "Translation"("lang");

View file

@ -0,0 +1,2 @@
ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0;
UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0;

View file

@ -0,0 +1,15 @@
CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR');
CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF');
ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess";
ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity";
ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2);
-- Seed des 6 carbets démo avec valeurs réalistes
UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara';
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou';
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury';
UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa';
UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou';
UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa';

View file

@ -0,0 +1,8 @@
CREATE TABLE "Favorite" (
"userId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId")
);
CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId");
CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId");

View file

@ -0,0 +1,112 @@
-- UserRole : ajouter RENTAL_PROVIDER
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
-- Enums dédiés
CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
-- RentalProvider
CREATE TABLE "RentalProvider" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isSystemD" BOOLEAN NOT NULL DEFAULT false,
"managedByUserId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"description" TEXT,
"commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"approved" BOOLEAN NOT NULL DEFAULT false,
"approvedAt" TIMESTAMP(3),
"approvedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
-- RentalItem
CREATE TABLE "RentalItem" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"category" "RentalCategory" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"imageUrl" TEXT,
"pricePerDay" DECIMAL(8,2) NOT NULL,
"pricePerWeek" DECIMAL(8,2),
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
"totalQty" INTEGER NOT NULL DEFAULT 1,
"withMotor" BOOLEAN NOT NULL DEFAULT false,
"fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
"requiresLicense" BOOLEAN NOT NULL DEFAULT false,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
-- RentalItemAvailability
CREATE TABLE "RentalItemAvailability" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"qty" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"rentalBookingId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
-- RentalBooking
CREATE TABLE "RentalBooking" (
"id" TEXT NOT NULL,
"bookingId" TEXT,
"tenantId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"itemsTotal" DECIMAL(10,2) NOT NULL,
"depositTotal" DECIMAL(10,2) NOT NULL,
"commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
"amount" DECIMAL(10,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"stripeSessionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
-- RentalLine
CREATE TABLE "RentalLine" (
"id" TEXT NOT NULL,
"rentalBookingId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"qty" INTEGER NOT NULL,
"pricePerDay" DECIMAL(8,2) NOT NULL,
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
"lineTotal" DECIMAL(10,2) NOT NULL,
CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");

View file

@ -0,0 +1,22 @@
-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
-- sortOrder pour cover (0). Cascade sur RentalItem.
CREATE TABLE "RentalItemMedia" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"type" "MediaType" NOT NULL,
"s3Key" TEXT NOT NULL,
"s3Url" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
ON "RentalItemMedia"("itemId", "sortOrder");
ALTER TABLE "RentalItemMedia"
ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -13,6 +13,7 @@ enum UserRole {
CE_MEMBER CE_MEMBER
TOURIST TOURIST
ADMIN ADMIN
RENTAL_PROVIDER
} }
enum CarbetStatus { enum CarbetStatus {
@ -59,6 +60,17 @@ enum SubscriptionStatus {
CANCELED CANCELED
} }
enum AccessType {
ROAD_AND_RIVER
RIVER_ONLY
}
enum TransportMode {
OWNER_PROVIDES
SELF_ARRANGE
PARTNER_PROVIDER
}
model Organization { model Organization {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@ -86,11 +98,13 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner") carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant") bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor") reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[] subscriptions Subscription[]
rentalProviders RentalProvider[]
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
@@index([organizationId]) @@index([organizationId])
@@index([role]) @@index([role])
@ -106,24 +120,66 @@ model Carbet {
latitude Decimal @db.Decimal(9, 6) latitude Decimal @db.Decimal(9, 6)
longitude Decimal @db.Decimal(9, 6) longitude Decimal @db.Decimal(9, 6)
embarkPoint String 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 capacity Int
// 4 critères opérationnels dealbreakers (dispo en filtres + badges UI)
roadAccess RoadAccess?
electricity Electricity?
gsmAtCarbet Boolean @default(false)
gsmExitDistanceKm Decimal? @db.Decimal(4, 2)
// Prix par nuit pour le carbet entier (toute capacité). En euros.
nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
// 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?
// Plugin pirogue-providers : qui organise le transport ?
transportMode TransportMode?
pirogueProviderId String?
status CarbetStatus @default(DRAFT) status CarbetStatus @default(DRAFT)
lastBookedAt DateTime? lastBookedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict) owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
amenities CarbetAmenity[] pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
media Media[] amenities CarbetAmenity[]
availabilities Availability[] media Media[]
bookings Booking[] availabilities Availability[]
reviews Review[] bookings Booking[]
reviews Review[]
subscriptions Subscription[] subscriptions Subscription[]
@@index([ownerId]) @@index([ownerId])
@@index([status]) @@index([status])
@@index([river]) @@index([river])
@@index([accessType])
@@index([pirogueProviderId])
}
model PirogueProvider {
id String @id @default(cuid())
name String
contactEmail String?
contactPhone String?
rivers String[] @default([])
pricingNote String?
description String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carbets Carbet[]
@@index([active])
} }
model Amenity { model Amenity {
@ -196,7 +252,8 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict) carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict) tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
review Review? review Review?
rentalBookings RentalBooking[]
@@index([carbetId]) @@index([carbetId])
@@index([tenantId]) @@index([tenantId])
@ -244,3 +301,247 @@ model Review {
@@index([carbetId]) @@index([carbetId])
@@index([authorId]) @@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])
}
model ContentPage {
slug String
lang String @default("fr")
title String
body String
// 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
category String @default("general")
published Boolean @default(true)
lastEditedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([slug, lang])
@@index([slug])
@@index([category])
@@index([published])
}
model AuditLog {
id String @id @default(cuid())
scope String
event String
target String?
actorEmail String?
details Json @default("{}")
createdAt DateTime @default(now())
@@index([scope])
@@index([event])
@@index([actorEmail])
@@index([createdAt])
}
model Setting {
key String @id
value Json @default("{}")
updatedAt DateTime @updatedAt
updatedBy String?
}
model Translation {
key String
lang String
value String
updatedAt DateTime @updatedAt
updatedBy String?
@@id([key, lang])
@@index([lang])
}
model PasswordResetToken {
tokenHash String @id
userId String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
model Favorite {
userId String
carbetId String
createdAt DateTime @default(now())
@@id([userId, carbetId])
@@index([userId])
@@index([carbetId])
}
enum RoadAccess {
NONE
DRY_SEASON_ONLY
ALL_YEAR
}
enum Electricity {
NONE
SOLAR
GENERATOR_READY
EDF
}
enum RentalCategory {
SLEEP
NAVIGATION
FISHING
COOKING
SAFETY
}
enum RentalBookingStatus {
PENDING
CONFIRMED
HANDED_OVER
RETURNED
CANCELLED
}
model RentalProvider {
id String @id @default(cuid())
name String
isSystemD Boolean @default(false)
managedByUserId String?
contactEmail String?
contactPhone String?
rivers String[] @default([])
description String?
commissionPct Decimal @db.Decimal(5, 2) @default(0)
active Boolean @default(true)
approved Boolean @default(false)
approvedAt DateTime?
approvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
items RentalItem[]
rentalBookings RentalBooking[]
@@index([active, approved])
@@index([managedByUserId])
}
model RentalItem {
id String @id @default(cuid())
providerId String
category RentalCategory
name String
description String?
imageUrl String?
pricePerDay Decimal @db.Decimal(8, 2)
pricePerWeek Decimal? @db.Decimal(8, 2)
deposit Decimal @db.Decimal(8, 2) @default(0)
totalQty Int @default(1)
withMotor Boolean @default(false)
fuelIncluded Boolean @default(false)
requiresLicense Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
availabilities RentalItemAvailability[]
lines RentalLine[]
media RentalItemMedia[]
@@index([providerId])
@@index([category, active])
}
model RentalItemMedia {
id String @id @default(cuid())
itemId String
type MediaType
s3Key String
s3Url String
sortOrder Int @default(0)
createdAt DateTime @default(now())
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId, sortOrder])
}
model RentalItemAvailability {
id String @id @default(cuid())
itemId String
startDate DateTime
endDate DateTime
qty Int
reason String
rentalBookingId String?
createdAt DateTime @default(now())
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId, startDate, endDate])
@@index([rentalBookingId])
}
model RentalBooking {
id String @id @default(cuid())
bookingId String?
tenantId String
providerId String
startDate DateTime
endDate DateTime
status RentalBookingStatus @default(PENDING)
paymentStatus PaymentStatus @default(PENDING)
itemsTotal Decimal @db.Decimal(10, 2)
depositTotal Decimal @db.Decimal(10, 2)
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
amount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
stripeSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
lines RentalLine[]
@@index([tenantId, status])
@@index([providerId, status])
@@index([bookingId])
@@index([startDate, endDate])
}
model RentalLine {
id String @id @default(cuid())
rentalBookingId String
itemId String
qty Int
pricePerDay Decimal @db.Decimal(8, 2)
deposit Decimal @db.Decimal(8, 2) @default(0)
lineTotal Decimal @db.Decimal(10, 2)
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([rentalBookingId])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
public/icons/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -0,0 +1,60 @@
{
"name": "Karbé — carbets fluviaux de Guyane",
"short_name": "Karbé",
"description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.",
"start_url": "/decouvrir",
"id": "/decouvrir",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#000000",
"theme_color": "#059669",
"lang": "fr",
"categories": ["travel", "lifestyle"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Au fil de l'eau",
"short_name": "Découvrir",
"url": "/decouvrir",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Mes favoris",
"short_name": "Favoris",
"url": "/mes-favoris",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Mon compte",
"short_name": "Compte",
"url": "/mon-compte",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
}
]
}

54
scripts/backup-postgres.sh Executable file
View file

@ -0,0 +1,54 @@
#!/bin/bash
#
# Backup nightly du PostgreSQL Karbé vers MinIO.
# Lancé par un systemd timer (karbe-backup.timer).
#
# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un
# nettoyage côté `mc rm` planifié — TODO si on veut être propre).
set -euo pipefail
STAMP=$(date -u +%Y%m%d-%H%M%S)
DUMP_DIR=/tmp/karbe-backup
DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz"
BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz"
mkdir -p "$DUMP_DIR"
# Dump compressé depuis le conteneur postgres
docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \
-f /home/ubuntu/karbe/docker-compose.override.yml \
exec -T postgres pg_dump -U karbe -d karbe \
| gzip > "$DUMP_FILE"
SIZE=$(stat -c %s "$DUMP_FILE")
echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}"
# Push vers MinIO via mc Docker
docker run --rm --network karbe-net \
--entrypoint /bin/sh \
-v "$DUMP_DIR:/dump" \
-e MINIO_ROOT_USER \
-e MINIO_ROOT_PASSWORD \
minio/mc:latest -c "
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \
mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST}
"
echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
# Nettoyage local
rm -f "$DUMP_FILE"
# Rétention : supprime les backups > 30 jours dans MinIO
docker run --rm --network karbe-net \
--entrypoint /bin/sh \
-e MINIO_ROOT_USER \
-e MINIO_ROOT_PASSWORD \
minio/mc:latest -c "
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true
"
echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)"

35
scripts/upload-aquarelles.sh Executable file
View file

@ -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
"

19
src/app/a-propos/page.tsx Normal file
View file

@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { getContentPage } from "@/lib/content-pages";
import { getLocale } from "@/lib/i18n/server";
import { isPluginEnabled } from "@/lib/plugins/server";
import { ContentPageRenderer } from "@/components/ContentPageRenderer";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
const page = await getContentPage("a-propos", await getLocale());
return { title: page?.title ?? "À propos" };
}
export default async function AboutPage() {
if (!(await isPluginEnabled("content-pages"))) notFound();
const page = await getContentPage("a-propos", await getLocale());
if (!page) notFound();
return <ContentPageRenderer page={page} />;
}

60
src/app/accueil/page.tsx Normal file
View file

@ -0,0 +1,60 @@
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";
export const metadata = { title: "Accueil — Karbé" };
/**
* Landing « marketing » historique (hero + sections + footer riche). Conservée
* à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
*/
export default function LandingPage() {
return (
<>
<IfPluginEnabled
plugin="landing-hero"
fallback={
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
Karbé carbets fluviaux de Guyane
</h1>
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
La marketplace pour louer des carbets le long des fleuves de Guyane.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/decouvrir"
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
Au fil de l&apos;eau
</Link>
<Link
href="/carbets"
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
>
Catalogue
</Link>
</div>
</main>
</div>
}
>
<HeroSection />
</IfPluginEnabled>
<IfPluginEnabled plugin="landing-sections">
<ExperiencesSection />
<HowItWorksSection />
<CESection />
<TestimonialsSection />
<LandingFooter />
</IfPluginEnabled>
</>
);
}

View file

@ -0,0 +1,134 @@
import Link from "next/link";
import { listAuditAdmin, listAuditScopes } from "@/lib/admin/audit";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
scope?: string;
actor?: string;
from?: string;
to?: string;
}>;
};
function parseDate(v?: string): Date | undefined {
if (!v) return undefined;
const d = new Date(v);
return isNaN(d.getTime()) ? undefined : d;
}
export default async function AuditAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
scope: sp.scope?.trim() || undefined,
actor: sp.actor?.trim() || undefined,
from: parseDate(sp.from),
to: parseDate(sp.to),
};
const [rows, scopes] = await Promise.all([listAuditAdmin(filters), listAuditScopes()]);
const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit", month: "short", year: "2-digit", hour: "2-digit", minute: "2-digit",
});
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Audit log</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} entrée{rows.length > 1 ? "s" : ""}
{rows.length === 300 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche événement, cible, acteur…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="scope"
defaultValue={filters.scope ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous scopes</option>
{scopes.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input
type="text"
name="actor"
defaultValue={filters.actor ?? ""}
placeholder="Acteur (email)"
className="rounded-md border border-zinc-300 px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<label className="flex items-center gap-1 text-xs text-zinc-500">
Du
<input type="date" name="from" defaultValue={sp.from ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
</label>
<label className="flex items-center gap-1 text-xs text-zinc-500">
au
<input type="date" name="to" defaultValue={sp.to ?? ""} className="rounded-md border border-zinc-300 px-2 py-1 text-sm" />
</label>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.scope || filters.actor || filters.from || filters.to) ? (
<Link href="/admin/audit" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-3 py-2 text-left font-semibold">Quand</th>
<th className="px-3 py-2 text-left font-semibold">Scope</th>
<th className="px-3 py-2 text-left font-semibold">Événement</th>
<th className="px-3 py-2 text-left font-semibold">Cible</th>
<th className="px-3 py-2 text-left font-semibold">Acteur</th>
<th className="px-3 py-2 text-left font-semibold">Détails</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-sm text-zinc-500">
Aucune entrée d&apos;audit ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((r) => (
<tr key={r.id} className="hover:bg-zinc-50 align-top">
<td className="px-3 py-2 text-[11px] font-mono text-zinc-500 whitespace-nowrap">
{dateTimeFmt.format(r.createdAt)}
</td>
<td className="px-3 py-2 text-xs text-zinc-700 whitespace-nowrap">{r.scope}</td>
<td className="px-3 py-2 font-mono text-xs text-zinc-900 whitespace-nowrap">{r.event}</td>
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
{r.target ? r.target.slice(0, 24) + (r.target.length > 24 ? "…" : "") : "—"}
</td>
<td className="px-3 py-2 text-xs text-zinc-700">{r.actorEmail ?? "—"}</td>
<td className="px-3 py-2 font-mono text-[11px] text-zinc-600">
{r.details && typeof r.details === "object" && Object.keys(r.details as object).length > 0
? JSON.stringify(r.details)
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,156 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
import {
refundBookingAction,
updateBookingPaymentAction,
updateBookingStatusAction,
} from "../../actions";
type Status = (typeof BookingStatus)[keyof typeof BookingStatus];
type Payment = (typeof PaymentStatus)[keyof typeof PaymentStatus];
const btnBase =
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
export function BookingActions({
id,
status,
paymentStatus,
}: {
id: string;
status: Status;
paymentStatus: Payment;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmRefund, setConfirmRefund] = useState(false);
function setStatus(next: Status) {
setError(null);
startTransition(async () => {
const res = await updateBookingStatusAction(id, next);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function setPayment(next: Payment) {
setError(null);
startTransition(async () => {
const res = await updateBookingPaymentAction(id, next);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function refund() {
setError(null);
startTransition(async () => {
await refundBookingAction(id);
setConfirmRefund(false);
router.refresh();
});
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-zinc-500">Statut résa :</span>
{status === BookingStatus.PENDING ? (
<button
type="button"
disabled={pending}
onClick={() => setStatus(BookingStatus.CONFIRMED)}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Confirmer
</button>
) : null}
{status === BookingStatus.CONFIRMED ? (
<button
type="button"
disabled={pending}
onClick={() => setStatus(BookingStatus.COMPLETED)}
className={`${btnBase} border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50`}
>
Marquer terminé
</button>
) : null}
{status !== BookingStatus.CANCELLED && status !== BookingStatus.COMPLETED ? (
<button
type="button"
disabled={pending}
onClick={() => setStatus(BookingStatus.CANCELLED)}
className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
>
Annuler
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-zinc-500">Paiement :</span>
{paymentStatus !== PaymentStatus.SUCCEEDED && paymentStatus !== PaymentStatus.REFUNDED ? (
<button
type="button"
disabled={pending}
onClick={() => setPayment(PaymentStatus.SUCCEEDED)}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer payé
</button>
) : null}
{paymentStatus !== PaymentStatus.FAILED && paymentStatus !== PaymentStatus.REFUNDED ? (
<button
type="button"
disabled={pending}
onClick={() => setPayment(PaymentStatus.FAILED)}
className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
>
Marquer échec
</button>
) : null}
{paymentStatus === PaymentStatus.SUCCEEDED ? (
confirmRefund ? (
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
<span className="text-xs text-amber-900">Rembourser & annuler ?</span>
<button
type="button"
onClick={refund}
disabled={pending}
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmRefund(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmRefund(true)}
disabled={pending}
className={`${btnBase} border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100`}
>
Rembourser
</button>
)
) : null}
</div>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,121 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getBookingForAdmin } from "@/lib/admin/bookings";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { BookingActions } from "./_components/BookingActions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function BookingDetailPage({ params }: PageProps) {
const { id } = await params;
const booking = await getBookingForAdmin(id);
if (!booking) notFound();
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
});
const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000));
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2">
<Link href="/admin/bookings" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les réservations
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
Réservation <code className="text-base text-zinc-500">{booking.id.slice(0, 12)}</code>
<StatusBadge status={booking.status} />
<StatusBadge status={booking.paymentStatus} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
Créée le {dateTimeFmt.format(booking.createdAt)} · MAJ {dateTimeFmt.format(booking.updatedAt)}
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Actions</h2>
<BookingActions id={booking.id} status={booking.status} paymentStatus={booking.paymentStatus} />
</section>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour</h2>
<dl className="space-y-2 text-sm">
<Row label="Du" value={dateFmt.format(booking.startDate)} />
<Row label="Au" value={dateFmt.format(booking.endDate)} />
<Row label="Durée" value={`${nights} nuit${nights > 1 ? "s" : ""}`} />
<Row label="Voyageurs" value={String(booking.guestCount)} />
<Row label="Montant" value={`${Number(booking.amount).toFixed(2)} ${booking.currency}`} />
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Carbet</h2>
<dl className="space-y-2 text-sm">
<Row
label="Titre"
value={
<Link href={`/admin/carbets/${booking.carbet.id}`} className="font-medium text-zinc-900 hover:underline">
{booking.carbet.title}
</Link>
}
/>
<Row label="Slug" value={<code>/{booking.carbet.slug}</code>} />
<Row label="Fleuve" value={booking.carbet.river} />
<Row
label="Propriétaire"
value={
<Link href={`/admin/users/${booking.carbet.owner.id}`} className="text-zinc-900 hover:underline">
{booking.carbet.owner.firstName} {booking.carbet.owner.lastName}
</Link>
}
/>
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Locataire</h2>
<dl className="space-y-2 text-sm">
<Row
label="Nom"
value={
<Link href={`/admin/users/${booking.tenant.id}`} className="text-zinc-900 hover:underline">
{booking.tenant.firstName} {booking.tenant.lastName}
</Link>
}
/>
<Row label="Email" value={booking.tenant.email} />
{booking.tenant.phone ? <Row label="Téléphone" value={booking.tenant.phone} /> : null}
<Row label="Rôle" value={booking.tenant.role} />
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Avis</h2>
{booking.review ? (
<p className="text-sm text-zinc-700">
Note <strong>{booking.review.rating}/5</strong> · déposé le {dateFmt.format(booking.review.createdAt)} ·{" "}
<Link href={`/admin/reviews?q=${booking.review.id}`} className="text-zinc-900 hover:underline">
Voir l&apos;avis
</Link>
</p>
) : (
<p className="text-sm text-zinc-500">Pas encore d&apos;avis pour cette réservation.</p>
)}
</section>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 pb-1.5 last:border-b-0 last:pb-0">
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="text-sm text-zinc-900">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,108 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendBookingConfirmed, sendBookingRefunded } from "@/lib/email";
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
}
const ALLOWED_STATUS = new Set<string>([
BookingStatus.PENDING,
BookingStatus.CONFIRMED,
BookingStatus.CANCELLED,
BookingStatus.COMPLETED,
]);
const ALLOWED_PAYMENT = new Set<string>([
PaymentStatus.PENDING,
PaymentStatus.AUTHORIZED,
PaymentStatus.SUCCEEDED,
PaymentStatus.FAILED,
PaymentStatus.REFUNDED,
]);
export async function updateBookingStatusAction(id: string, status: string) {
await requireRole([UserRole.ADMIN]);
if (!ALLOWED_STATUS.has(status)) {
return { ok: false as const, error: "Statut invalide" };
}
const session = await auth();
const before = await prisma.booking.findUnique({
where: { id },
select: { status: true },
});
const updated = await prisma.booking.update({
where: { id },
data: { status: status as BookingStatus },
include: {
tenant: { select: { email: true, firstName: true } },
carbet: { select: { title: true } },
},
});
await audit("booking.status.update", id, session?.user?.email ?? null, { status });
if (
before?.status !== BookingStatus.CONFIRMED &&
updated.status === BookingStatus.CONFIRMED
) {
sendBookingConfirmed(
updated.tenant.email,
updated.tenant.firstName,
updated.id,
updated.carbet.title,
updated.startDate,
updated.endDate,
).catch(() => {});
}
revalidatePath("/admin/bookings");
revalidatePath(`/admin/bookings/${id}`);
return { ok: true as const };
}
export async function updateBookingPaymentAction(id: string, paymentStatus: string) {
await requireRole([UserRole.ADMIN]);
if (!ALLOWED_PAYMENT.has(paymentStatus)) {
return { ok: false as const, error: "Statut de paiement invalide" };
}
const session = await auth();
await prisma.booking.update({
where: { id },
data: { paymentStatus: paymentStatus as PaymentStatus },
});
await audit("booking.payment.update", id, session?.user?.email ?? null, { paymentStatus });
revalidatePath("/admin/bookings");
revalidatePath(`/admin/bookings/${id}`);
return { ok: true as const };
}
export async function refundBookingAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const updated = await prisma.booking.update({
where: { id },
data: {
paymentStatus: PaymentStatus.REFUNDED,
status: BookingStatus.CANCELLED,
},
include: {
tenant: { select: { email: true, firstName: true } },
carbet: { select: { title: true } },
},
});
await audit("booking.refund", id, session?.user?.email ?? null, {});
sendBookingRefunded(
updated.tenant.email,
updated.tenant.firstName,
updated.id,
updated.carbet.title,
updated.amount.toString(),
updated.currency,
).catch(() => {});
revalidatePath("/admin/bookings");
revalidatePath(`/admin/bookings/${id}`);
return { ok: true as const };
}

View file

@ -0,0 +1,184 @@
import Link from "next/link";
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
import { listBookingsAdmin } from "@/lib/admin/bookings";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
status?: string;
paymentStatus?: string;
from?: string;
to?: string;
}>;
};
const STATUS_VALUES = new Set<string>([
BookingStatus.PENDING,
BookingStatus.CONFIRMED,
BookingStatus.CANCELLED,
BookingStatus.COMPLETED,
]);
const PAYMENT_VALUES = new Set<string>([
PaymentStatus.PENDING,
PaymentStatus.AUTHORIZED,
PaymentStatus.SUCCEEDED,
PaymentStatus.FAILED,
PaymentStatus.REFUNDED,
]);
function parseDate(v?: string): Date | undefined {
if (!v) return undefined;
const d = new Date(v);
return isNaN(d.getTime()) ? undefined : d;
}
export default async function BookingsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as BookingStatus) : undefined,
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "")
? (sp.paymentStatus as PaymentStatus)
: undefined,
from: parseDate(sp.from),
to: parseDate(sp.to),
};
const bookings = await listBookingsAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Réservations</h1>
<p className="mt-1 text-sm text-zinc-500">
{bookings.length} résultat{bookings.length > 1 ? "s" : ""}
{bookings.length === 200 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche ID, locataire, carbet…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="status"
defaultValue={filters.status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous statuts</option>
<option value={BookingStatus.PENDING}>En attente</option>
<option value={BookingStatus.CONFIRMED}>Confirmé</option>
<option value={BookingStatus.CANCELLED}>Annulé</option>
<option value={BookingStatus.COMPLETED}>Terminé</option>
</select>
<select
name="paymentStatus"
defaultValue={filters.paymentStatus ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous paiements</option>
<option value={PaymentStatus.PENDING}>En attente</option>
<option value={PaymentStatus.AUTHORIZED}>Autorisé</option>
<option value={PaymentStatus.SUCCEEDED}>Payé</option>
<option value={PaymentStatus.FAILED}>Échec</option>
<option value={PaymentStatus.REFUNDED}>Remboursé</option>
</select>
<label className="flex items-center gap-1 text-xs text-zinc-500">
Du
<input
type="date"
name="from"
defaultValue={sp.from ?? ""}
className="rounded-md border border-zinc-300 px-2 py-1 text-sm"
/>
</label>
<label className="flex items-center gap-1 text-xs text-zinc-500">
au
<input
type="date"
name="to"
defaultValue={sp.to ?? ""}
className="rounded-md border border-zinc-300 px-2 py-1 text-sm"
/>
</label>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.status || filters.paymentStatus || filters.from || filters.to) ? (
<Link href="/admin/bookings" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">ID</th>
<th className="px-4 py-2 text-left font-semibold">Carbet</th>
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
<th className="px-4 py-2 text-left font-semibold">Séjour</th>
<th className="px-4 py-2 text-right font-semibold">Pers.</th>
<th className="px-4 py-2 text-right font-semibold">Montant</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
<th className="px-4 py-2 text-right font-semibold">Créé</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{bookings.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune réservation ne correspond aux filtres.
</td>
</tr>
) : null}
{bookings.map((b) => (
<tr key={b.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/bookings/${b.id}`} className="font-mono text-[11px] text-zinc-900 hover:underline">
{b.id.slice(0, 10)}
</Link>
</td>
<td className="px-4 py-2">
<Link href={`/admin/carbets/${b.carbet.id}`} className="font-medium text-zinc-900 hover:underline">
{b.carbet.title}
</Link>
<div className="text-[11px] text-zinc-500">
<code>/{b.carbet.slug}</code>
</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{b.tenant.firstName} {b.tenant.lastName}
<div className="text-[11px] text-zinc-500">{b.tenant.email}</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{dateFmt.format(b.startDate)} {dateFmt.format(b.endDate)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{b.guestCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900">
{Number(b.amount).toFixed(2)} {b.currency}
</td>
<td className="px-4 py-2"><StatusBadge status={b.status} /></td>
<td className="px-4 py-2"><StatusBadge status={b.paymentStatus} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{dateFmt.format(b.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,141 @@
"use client";
import { useState, useTransition } from "react";
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<string | null>(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 (
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Médias ({media.length})</h2>
{media.length === 0 ? (
<p className="mb-4 rounded border border-dashed border-zinc-300 bg-zinc-50 p-4 text-sm text-zinc-500">
Aucun média. Ajoute une URL ci-dessous (MinIO, CDN externe, ).
</p>
) : (
<ul className="mb-4 divide-y divide-zinc-100 rounded border border-zinc-200">
{media.map((m, i) => (
<li key={m.id} className="flex items-center gap-3 px-3 py-2">
<span className="font-mono text-xs text-zinc-500">#{i + 1}</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={m.s3Url}
alt=""
className="h-12 w-16 rounded object-cover ring-1 ring-zinc-200"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-zinc-700">{m.s3Url}</div>
<div className="text-[11px] text-zinc-500">
{m.type} · <code>{m.s3Key}</code>
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => reorder(m.id, "up")}
disabled={pending || i === 0}
className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => reorder(m.id, "down")}
disabled={pending || i === media.length - 1}
className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => remove(m.id)}
disabled={pending}
className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
<form action={addByUrl} className="space-y-3 rounded border border-zinc-200 bg-zinc-50 p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Ajouter un média par URL</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<FormField label="URL" className="sm:col-span-3">
<input
name="url"
type="url"
required
className={inputCls}
placeholder="https://media.karbe.cosmolan.fr/…"
/>
</FormField>
<FormField label="Type">
<select name="type" defaultValue="PHOTO" className={selectCls}>
<option value="PHOTO">Photo</option>
<option value="VIDEO">Vidéo</option>
</select>
</FormField>
</div>
{/* Le serveur calcule un s3Key déterministe à partir de l'URL si vide. */}
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
<div className="flex justify-end">
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Ajout…" : "Ajouter"}
</button>
</div>
</form>
</div>
);
}

View file

@ -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 (
<div className="flex flex-wrap items-center gap-2">
{current === CarbetStatus.DRAFT ? (
<button
type="button"
onClick={() => setStatus(CarbetStatus.PUBLISHED)}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Publier
</button>
) : null}
{current === CarbetStatus.PUBLISHED ? (
<button
type="button"
onClick={() => setStatus(CarbetStatus.DRAFT)}
disabled={pending}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
>
Dépublier (brouillon)
</button>
) : null}
{current !== CarbetStatus.ARCHIVED ? (
confirmArchive ? (
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
<span className="text-xs text-amber-900">Sûr ?</span>
<button
type="button"
onClick={archive}
disabled={pending}
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
>
Oui, archiver
</button>
<button
type="button"
onClick={() => setConfirmArchive(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmArchive(true)}
disabled={pending}
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
>
Archiver
</button>
)
) : (
<button
type="button"
onClick={() => setStatus(CarbetStatus.DRAFT)}
disabled={pending}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
>
Réactiver (brouillon)
</button>
)}
</div>
);
}

View file

@ -0,0 +1,113 @@
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 { MediaUploader } from "@/components/MediaUploader";
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 (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/carbets" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les carbets
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{carbet.title}
<StatusBadge status={carbet.status} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
<code>/{carbet.slug}</code> · {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)}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<StatusActions id={carbet.id} current={carbet.status} />
{carbet.status === "PUBLISHED" ? (
<a
href={`/carbets/${carbet.slug}`}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-500 hover:text-zinc-900"
>
Voir la fiche publique
</a>
) : null}
</div>
</header>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Médias
</h2>
<MediaUploader
carbetId={carbet.id}
initialMedia={carbet.media.map((m) => ({
id: m.id,
type: m.type,
s3Key: m.s3Key,
s3Url: m.s3Url,
sortOrder: m.sortOrder,
}))}
/>
</section>
<CarbetForm
owners={owners}
providers={providers}
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
ownerId: carbet.owner.id,
title: carbet.title,
slug: carbet.slug,
description: carbet.description,
river: carbet.river,
embarkPoint: carbet.embarkPoint,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
capacity: carbet.capacity,
nightlyPrice: carbet.nightlyPrice.toString(),
accessType: carbet.accessType,
roadAccess: carbet.roadAccess,
electricity: carbet.electricity,
gsmAtCarbet: carbet.gsmAtCarbet,
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null,
roadAccessNote: carbet.roadAccessNote,
pirogueDurationMin: carbet.pirogueDurationMin,
minStayNights: carbet.minStayNights,
maxStayNights: carbet.maxStayNights,
minCapacity: carbet.minCapacity,
transportMode: carbet.transportMode,
pirogueProviderId: carbet.pirogueProvider?.id ?? null,
status: carbet.status,
}}
/>
</div>
);
}

View file

@ -0,0 +1,342 @@
"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/carbet-options";
export type CarbetFormInitial = {
ownerId?: string;
title?: string;
slug?: string;
description?: string;
river?: string;
embarkPoint?: string;
latitude?: number | string;
longitude?: number | string;
capacity?: number;
nightlyPrice?: number | string;
accessType?: string;
roadAccess?: string | null;
electricity?: string | null;
gsmAtCarbet?: boolean;
gsmExitDistanceKm?: number | string | null;
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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<form action={onSubmit} className="space-y-6">
<fieldset disabled={pending} className="space-y-6">
{/* Identité */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Titre" required>
<input name="title" defaultValue={initial.title ?? ""} className={inputCls} required maxLength={200} />
</FormField>
<FormField label="Slug" required hint="URL publique : /carbets/<slug>">
<input
name="slug"
defaultValue={initial.slug ?? ""}
className={inputCls}
required
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
placeholder="ex. karbe-awara-maroni"
/>
</FormField>
<FormField label="Propriétaire" required className="sm:col-span-2">
<select name="ownerId" defaultValue={initial.ownerId ?? ""} className={selectCls} required>
<option value="" disabled> sélectionner un propriétaire </option>
{owners.map((o) => (
<option key={o.id} value={o.id}>
{o.firstName} {o.lastName} ({o.email})
</option>
))}
</select>
</FormField>
<FormField label="Description" required className="sm:col-span-2" hint="Markdown léger autorisé.">
<textarea
name="description"
rows={6}
defaultValue={initial.description ?? ""}
className={textareaCls}
required
maxLength={20000}
/>
</FormField>
</div>
</section>
{/* Localisation */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Localisation</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Fleuve" required>
<input name="river" defaultValue={initial.river ?? ""} className={inputCls} required maxLength={100} placeholder="Maroni" />
</FormField>
<FormField label="Point d'embarquement" required>
<input
name="embarkPoint"
defaultValue={initial.embarkPoint ?? ""}
className={inputCls}
required
maxLength={200}
/>
</FormField>
<FormField label="Latitude" required hint="Décimal (-90 à 90)">
<input
name="latitude"
type="number"
step="0.000001"
defaultValue={initial.latitude?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
<FormField label="Longitude" required hint="Décimal (-180 à 180)">
<input
name="longitude"
type="number"
step="0.000001"
defaultValue={initial.longitude?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
</div>
</section>
{/* Accès */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Accès & transport</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Type d'accès" required>
<select name="accessType" defaultValue={initial.accessType ?? "ROAD_AND_RIVER"} className={selectCls} required>
{ACCESS_TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
<FormField label="Durée pirogue (min)" hint="Optionnel — vide si accès route uniquement">
<input
name="pirogueDurationMin"
type="number"
min={0}
max={1440}
defaultValue={initial.pirogueDurationMin?.toString() ?? ""}
className={inputCls}
/>
</FormField>
<FormField label="Note d'accès route" className="sm:col-span-2" hint="GPS, type de piste, distance dernière ville…">
<textarea
name="roadAccessNote"
rows={2}
defaultValue={initial.roadAccessNote ?? ""}
className={textareaCls}
maxLength={1000}
/>
</FormField>
<FormField label="Mode de transport pirogue">
<select name="transportMode" defaultValue={initial.transportMode ?? ""} className={selectCls}>
<option value=""> non spécifié </option>
{TRANSPORT_MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
<FormField label="Prestataire pirogue partenaire">
<select name="pirogueProviderId" defaultValue={initial.pirogueProviderId ?? ""} className={selectCls}>
<option value=""> aucun </option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.rivers.join(", ")})
</option>
))}
</select>
</FormField>
</div>
</section>
{/* Critères opérationnels */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Critères opérationnels
</h2>
<p className="mb-4 text-xs text-zinc-500">
Les 4 dealbreakers d&apos;un séjour en carbet guyanais. Indispensable pour les filtres recherche.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="🛣️ Accès route" hint="Praticabilité de l'accès depuis la route">
<select name="roadAccess" defaultValue={initial.roadAccess ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="ALL_YEAR">🛣 Toute saison</option>
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
<option value="NONE">🛶 Pirogue uniquement</option>
</select>
</FormField>
<FormField label="⚡ Électricité" hint="Comment est alimenté le carbet ?">
<select name="electricity" defaultValue={initial.electricity ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="EDF"> EDF / raccordé réseau</option>
<option value="GENERATOR_READY">🔌 Préinstallation groupe électrogène</option>
<option value="SOLAR"> Solaire</option>
<option value="NONE">🕯 Aucune électricité</option>
</select>
</FormField>
<FormField label="📶 Réseau GSM au carbet" hint="Téléphone capte directement sur place ?">
<select
name="gsmAtCarbet"
defaultValue={initial.gsmAtCarbet ? "yes" : "no"}
className={selectCls}
>
<option value="yes"> Oui, signal au carbet</option>
<option value="no"> Non, zone sans réseau</option>
</select>
</FormField>
<FormField
label="📵 Distance pour atteindre le réseau (km)"
hint="Si pas de réseau au carbet — sinon laisser vide"
>
<input
name="gsmExitDistanceKm"
type="number"
min={0}
max={50}
step="0.1"
defaultValue={initial.gsmExitDistanceKm?.toString() ?? ""}
placeholder="ex. 1.5"
className={inputCls}
/>
</FormField>
</div>
</section>
{/* Séjour & tarif */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour &amp; tarif</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<FormField label="Capacité" required hint="Voyageurs max">
<input
name="capacity"
type="number"
min={1}
max={100}
defaultValue={initial.capacity?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
<FormField label="Prix / nuit (€)" required hint="Pour le carbet entier.">
<input
name="nightlyPrice"
type="number"
min={0}
step="0.01"
defaultValue={initial.nightlyPrice?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
<FormField label="Capacité min recommandée" hint="Facultatif">
<input
name="minCapacity"
type="number"
min={1}
max={100}
defaultValue={initial.minCapacity?.toString() ?? ""}
className={inputCls}
/>
</FormField>
<FormField label="Nuits min" hint="Facultatif">
<input
name="minStayNights"
type="number"
min={1}
max={365}
defaultValue={initial.minStayNights?.toString() ?? ""}
className={inputCls}
/>
</FormField>
<FormField label="Nuits max" hint="Facultatif">
<input
name="maxStayNights"
type="number"
min={1}
max={365}
defaultValue={initial.maxStayNights?.toString() ?? ""}
className={inputCls}
/>
</FormField>
</div>
</section>
{/* Publication */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Publication</h2>
<FormField label="Statut" hint="Brouillon n'apparaît pas sur le site public. Archivé reste en base mais non listé.">
<select name="status" defaultValue={initial.status ?? "DRAFT"} className={selectCls}>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
</section>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,229 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import {
AccessType,
CarbetStatus,
Electricity,
MediaType,
RoadAccess,
TransportMode,
UserRole,
} from "@/generated/prisma/enums";
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
const baseCarbetSchema = z.object({
ownerId: z.string().min(1, "Propriétaire requis"),
title: z.string().trim().min(1).max(200),
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
description: z.string().trim().min(10).max(20000),
river: z.string().trim().min(2).max(100),
embarkPoint: z.string().trim().min(2).max(200),
latitude: z.coerce.number().min(-90).max(90),
longitude: z.coerce.number().min(-180).max(180),
capacity: z.coerce.number().int().min(1).max(100),
nightlyPrice: z.coerce.number().min(0).max(100000),
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
roadAccess: z
.enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR])
.optional()
.nullable(),
electricity: z
.enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF])
.optional()
.nullable(),
gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()),
gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(),
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
maxStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
minCapacity: z.coerce.number().int().min(1).max(100).optional().nullable(),
transportMode: z
.enum([TransportMode.OWNER_PROVIDES, TransportMode.SELF_ARRANGE, TransportMode.PARTNER_PROVIDER])
.optional()
.nullable(),
pirogueProviderId: z.string().optional().nullable(),
status: z.enum([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]).default(CarbetStatus.DRAFT),
});
function normalizeNullable<T>(v: T | "" | undefined | null): T | null {
if (v === undefined || v === null || v === "") return null;
return v;
}
function parseFromFormData(fd: FormData) {
const obj: Record<string, unknown> = {};
for (const [k, v] of fd.entries()) {
if (typeof v === "string") obj[k] = v;
}
// Normalise les champs optionnels nullables
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
);
// gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod)
if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no";
return obj;
}
export async function createCarbetAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
const created = await prisma.carbet.create({
data: {
...parsed.data,
lastBookedAt: null,
},
});
await audit("carbet.create", created.id, session?.user?.email ?? null, {
slug: created.slug,
status: created.status,
});
revalidatePath("/admin/carbets");
redirect(`/admin/carbets/${created.id}`);
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { ok: false as const, error: "Slug déjà utilisé" };
}
throw e;
}
}
export async function updateCarbetAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
const updated = await prisma.carbet.update({
where: { id },
data: parsed.data,
});
await audit("carbet.update", updated.id, session?.user?.email ?? null, {
slug: updated.slug,
status: updated.status,
});
revalidatePath("/admin/carbets");
revalidatePath(`/admin/carbets/${id}`);
revalidatePath(`/carbets/${updated.slug}`);
return { ok: true as const };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { ok: false as const, error: "Slug déjà utilisé" };
}
throw e;
}
}
export async function updateCarbetStatusAction(id: string, status: CarbetStatus) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.carbet.update({ where: { id }, data: { status } });
await audit("carbet.status", id, session?.user?.email ?? null, { status });
revalidatePath("/admin/carbets");
revalidatePath(`/admin/carbets/${id}`);
return { ok: true as const };
}
export async function deleteCarbetAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
// Soft : on archive plutôt que supprimer (bookings/reviews FK Restrict).
const archived = await prisma.carbet.update({
where: { id },
data: { status: CarbetStatus.ARCHIVED },
});
await audit("carbet.archive", id, session?.user?.email ?? null, { slug: archived.slug });
revalidatePath("/admin/carbets");
redirect("/admin/carbets");
}
const mediaSchema = z.object({
url: z.string().url().max(2000),
type: z.enum([MediaType.PHOTO, MediaType.VIDEO]).default(MediaType.PHOTO),
s3Key: z.string().max(500).optional(),
});
export async function addMediaAction(carbetId: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = mediaSchema.safeParse({
url: fd.get("url"),
type: fd.get("type") ?? "PHOTO",
s3Key: fd.get("s3Key") ?? undefined,
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
}
const existing = await prisma.media.count({ where: { carbetId } });
const session = await auth();
const m = await prisma.media.create({
data: {
carbetId,
type: parsed.data.type,
s3Url: parsed.data.url,
s3Key: parsed.data.s3Key ?? `external/${Date.now()}`,
sortOrder: existing,
},
});
await audit("media.create", m.id, session?.user?.email ?? null, { carbetId, url: parsed.data.url });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
export async function removeMediaAction(carbetId: string, mediaId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.media.delete({ where: { id: mediaId } });
await audit("media.delete", mediaId, session?.user?.email ?? null, { carbetId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
export async function reorderMediaAction(carbetId: string, mediaId: string, direction: "up" | "down") {
await requireRole([UserRole.ADMIN]);
const all = await prisma.media.findMany({
where: { carbetId },
orderBy: { sortOrder: "asc" },
});
const idx = all.findIndex((m) => m.id === mediaId);
if (idx === -1) return { ok: false as const };
const swap = direction === "up" ? idx - 1 : idx + 1;
if (swap < 0 || swap >= all.length) return { ok: false as const };
const a = all[idx];
const b = all[swap];
await prisma.$transaction([
prisma.media.update({ where: { id: a.id }, data: { sortOrder: b.sortOrder } }),
prisma.media.update({ where: { id: b.id }, data: { sortOrder: a.sortOrder } }),
]);
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
async function audit(
event: string,
entityId: string,
actor: string | null,
payload: Record<string, unknown>,
) {
await recordAudit({
scope: "admin.carbets",
event,
target: entityId,
actorEmail: actor,
details: payload,
});
}

View file

@ -0,0 +1,20 @@
import { listOwners, listPirogueProviders } from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { createCarbetAction } from "../actions";
export const dynamic = "force-dynamic";
export default async function NewCarbetPage() {
const [owners, providers] = await Promise.all([listOwners(), listPirogueProviders()]);
return (
<div className="mx-auto max-w-4xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Nouveau carbet</h1>
<p className="mt-1 text-sm text-zinc-500">
Crée un brouillon. Tu pourras le publier ensuite depuis sa fiche.
</p>
</header>
<CarbetForm owners={owners} providers={providers} action={createCarbetAction} submitLabel="Créer le carbet" />
</div>
);
}

View file

@ -0,0 +1,148 @@
import Link from "next/link";
import { AccessType, CarbetStatus } from "@/generated/prisma/enums";
import { listCarbetsAdmin, listDistinctRivers } from "@/lib/admin/carbets";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
river?: string;
status?: string;
accessType?: string;
}>;
};
const STATUS_VALUES = new Set<string>([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]);
const ACCESS_VALUES = new Set<string>([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]);
export default async function CarbetsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
river: sp.river || undefined,
status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as CarbetStatus) : undefined,
accessType: ACCESS_VALUES.has(sp.accessType ?? "") ? (sp.accessType as AccessType) : undefined,
};
const [carbets, rivers] = await Promise.all([listCarbetsAdmin(filters), listDistinctRivers()]);
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Carbets</h1>
<p className="mt-1 text-sm text-zinc-500">
{carbets.length} résultat{carbets.length > 1 ? "s" : ""} · brouillons, publiés et archivés
</p>
</div>
<Link
href="/admin/carbets/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouveau carbet
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche par titre, slug, fleuve…"
className="flex-1 min-w-[180px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous les fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select
name="status"
defaultValue={filters.status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous statuts</option>
<option value={CarbetStatus.DRAFT}>Brouillon</option>
<option value={CarbetStatus.PUBLISHED}>Publié</option>
<option value={CarbetStatus.ARCHIVED}>Archivé</option>
</select>
<select
name="accessType"
defaultValue={filters.accessType ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous accès</option>
<option value={AccessType.ROAD_AND_RIVER}>🛣 Route + fleuve</option>
<option value={AccessType.RIVER_ONLY}>🛶 Expédition</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.river || filters.status || filters.accessType) ? (
<Link href="/admin/carbets" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Titre</th>
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
<th className="px-4 py-2 text-left font-semibold">Accès</th>
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
<th className="px-4 py-2 text-right font-semibold">/nuit</th>
<th className="px-4 py-2 text-right font-semibold">Médias</th>
<th className="px-4 py-2 text-right font-semibold">Résas</th>
<th className="px-4 py-2 text-left font-semibold">Propriétaire</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{carbets.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun carbet ne correspond aux filtres.
</td>
</tr>
) : null}
{carbets.map((c) => (
<tr key={c.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/carbets/${c.id}`} className="font-medium text-zinc-900 hover:underline">
{c.title}
</Link>
<div className="text-[11px] text-zinc-500">
<code>/{c.slug}</code>
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{c.river}</td>
<td className="px-4 py-2 text-zinc-700">
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(c.nightlyPrice).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.mediaCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.bookingsCount}</td>
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
<td className="px-4 py-2"><StatusBadge status={c.status} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(c.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Page = {
slug: string;
lang: 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<string | null>(null);
const [err, setErr] = useState<string | null>(null);
async function save() {
setBusy(true);
setMsg(null);
setErr(null);
try {
const res = await fetch(
`/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`,
{
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 (
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-gray-700">Titre</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700">
Contenu (markdown léger : # ## ### gras italique [link](url) listes - 1. ---)
</span>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={24}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm leading-relaxed"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
/>
Publié
</label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={save}
disabled={busy}
className="rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white hover:bg-gray-800 disabled:opacity-50"
>
{busy ? "Sauvegarde…" : "Sauvegarder"}
</button>
{msg ? <span className="text-sm text-green-700">{msg}</span> : null}
{err ? <span className="text-sm text-red-700">{err}</span> : null}
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import EditorForm from "./_components/EditorForm";
export const dynamic = "force-dynamic";
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ lang?: string }>;
};
function normalizeLang(v: string | undefined): string {
if (!v) return "fr";
const l = v.toLowerCase().trim();
return /^[a-z]{2}$/.test(l) ? l : "fr";
}
export default async function EditContentPage({ params, searchParams }: PageProps) {
await requireRole([UserRole.ADMIN]);
const { slug } = await params;
const sp = await searchParams;
const lang = normalizeLang(sp.lang);
const [row, siblings] = await Promise.all([
prisma.contentPage.findUnique({ where: { slug_lang: { slug, lang } } }),
prisma.contentPage.findMany({
where: { slug },
select: { lang: true, title: true, published: true, updatedAt: true },
orderBy: { lang: "asc" },
}),
]);
if (!row) notFound();
const page = {
slug: row.slug,
lang: row.lang,
title: row.title,
body: row.body,
category: row.category,
published: row.published,
};
return (
<div className="mx-auto max-w-4xl">
<header className="mt-2">
<Link href="/admin/content-pages" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les pages
</Link>
<h1 className="mt-1 flex flex-wrap items-center gap-3 text-2xl font-semibold text-zinc-900">
{page.title}
<span className="rounded-full bg-zinc-900 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-white">
{page.lang}
</span>
</h1>
<p className="mt-1 text-sm text-zinc-500">
URL publique : <code>/{page.slug}</code>
{page.lang !== "fr" ? ` · variante ${page.lang}` : ""}
</p>
{siblings.length > 1 ? (
<nav className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-zinc-500">Versions :</span>
{siblings.map((s) => {
const active = s.lang === page.lang;
return (
<Link
key={s.lang}
href={`/admin/content-pages/${encodeURIComponent(slug)}?lang=${s.lang}`}
className={
"rounded-md px-2.5 py-1 font-semibold uppercase tracking-wider transition " +
(active
? "bg-zinc-900 text-white"
: "border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50")
}
title={s.title + (s.published ? "" : " (dépublié)")}
>
{s.lang}
{!s.published ? " ·" : ""}
</Link>
);
})}
</nav>
) : null}
</header>
<div className="mt-6">
<EditorForm page={page} />
</div>
</div>
);
}

View file

@ -0,0 +1,158 @@
import Link from "next/link";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { listContentPages } from "@/lib/content-pages";
export const dynamic = "force-dynamic";
const CATEGORY_LABEL: Record<string, string> = {
general: "Général",
legal: "Légales",
};
type Translation = {
lang: string;
title: string;
published: boolean;
updatedAt: Date;
};
type GroupedPage = {
slug: string;
category: string;
translations: Translation[];
};
export default async function ContentPagesAdminPage() {
await requireRole([UserRole.ADMIN]);
const rows = await listContentPages();
// Regrouper par slug — chaque slug peut avoir plusieurs traductions.
const bySlug = new Map<string, GroupedPage>();
for (const r of rows) {
const existing = bySlug.get(r.slug);
const t: Translation = {
lang: r.lang,
title: r.title,
published: r.published,
updatedAt: r.updatedAt,
};
if (existing) {
existing.translations.push(t);
} else {
bySlug.set(r.slug, { slug: r.slug, category: r.category, translations: [t] });
}
}
const pages = Array.from(bySlug.values()).sort((a, b) => a.slug.localeCompare(b.slug));
const byCategory = pages.reduce<Record<string, GroupedPage[]>>((acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
}, {});
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-5xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Pages éditoriales</h1>
<p className="mt-2 text-sm text-zinc-600">
Pages markdown servies par le site public. Chaque page existe en une ou
plusieurs langues utilisez le bouton de la langue voulue pour éditer
la bonne version.
</p>
</header>
<div className="space-y-8">
{Object.entries(byCategory).map(([cat, list]) => (
<section key={cat}>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">
{CATEGORY_LABEL[cat] ?? cat}
</h2>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Slug</th>
<th className="px-4 py-2 text-left font-semibold">Titre (FR)</th>
<th className="px-4 py-2 text-left font-semibold">Traductions</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
<th className="px-4 py-2 text-right font-semibold">Éditer</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{list.map((p) => {
const fr = p.translations.find((t) => t.lang === "fr");
const others = p.translations.filter((t) => t.lang !== "fr").sort((a, b) => a.lang.localeCompare(b.lang));
const lastUpdated = p.translations
.map((t) => t.updatedAt.getTime())
.reduce((a, b) => Math.max(a, b), 0);
return (
<tr key={p.slug} className="hover:bg-zinc-50">
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">/{p.slug}</td>
<td className="px-4 py-2">
{fr ? (
<>
<span className="font-medium text-zinc-900">{fr.title}</span>
{!fr.published ? (
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
dépublié
</span>
) : null}
</>
) : (
<span className="text-zinc-400"> (pas de version FR)</span>
)}
</td>
<td className="px-4 py-2 text-xs text-zinc-700">
{others.length === 0 ? (
<span className="text-zinc-400"></span>
) : (
<span className="flex flex-wrap gap-1">
{others.map((t) => (
<span
key={t.lang}
className={
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ring-1 ring-inset " +
(t.published
? "bg-emerald-100 text-emerald-800 ring-emerald-300"
: "bg-zinc-100 text-zinc-500 ring-zinc-300")
}
title={t.title}
>
{t.lang}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{lastUpdated ? dateFmt.format(new Date(lastUpdated)) : "—"}
</td>
<td className="px-4 py-2 text-right">
<span className="inline-flex flex-wrap justify-end gap-1">
{p.translations
.sort((a, b) => (a.lang === "fr" ? -1 : b.lang === "fr" ? 1 : a.lang.localeCompare(b.lang)))
.map((t) => (
<Link
key={t.lang}
href={`/admin/content-pages/${encodeURIComponent(p.slug)}?lang=${t.lang}`}
className="rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white hover:bg-zinc-800"
>
{t.lang}
</Link>
))}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,169 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { saveHomeTranslationsAction } from "../actions";
type Row = {
key: string;
baseFr: string;
baseEn: string;
overrideFr: string | null;
overrideEn: string | null;
};
type Section = {
id: string;
label: string;
description: string;
rows: Row[];
};
type Props = {
sections: Section[];
};
function autoRows(text: string): number {
const lines = text.split("\n").length;
return Math.min(8, Math.max(1, lines));
}
export function HomeTranslationsForm({ sections }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// État local : on garde uniquement la valeur courante (initialisée avec override ?? base).
// Le baseValue est posé en input caché et sert au backend pour décider override vs reset.
const initial = useMemo(() => {
const m = new Map<string, { fr: string; en: string }>();
for (const s of sections) {
for (const r of s.rows) {
m.set(r.key, { fr: r.overrideFr ?? r.baseFr, en: r.overrideEn ?? r.baseEn });
}
}
return m;
}, [sections]);
function onSubmit(formData: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await saveHomeTranslationsAction(formData);
if (res.ok === false) {
setError(res.error);
} else {
const parts: string[] = [];
if (res.saved) parts.push(`${res.saved} sauvegardé${res.saved > 1 ? "s" : ""}`);
if (res.reset) parts.push(`${res.reset} réinitialisé${res.reset > 1 ? "s" : ""} (valeur de base)`);
setSuccess(parts.length > 0 ? parts.join(" · ") : "Aucun changement.");
}
});
}
// On crée un seul formulaire global qui contient toutes les sections.
let counter = 0;
return (
<form action={onSubmit} className="space-y-8">
<fieldset disabled={pending} className="space-y-8">
{sections.map((section) => (
<section key={section.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<header className="mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
{section.label}
</h2>
<p className="mt-0.5 text-xs text-zinc-500">{section.description}</p>
</header>
<div className="space-y-4">
{section.rows.map((r) => {
const idxFr = counter++;
const idxEn = counter++;
const init = initial.get(r.key)!;
const hasOverrideFr = r.overrideFr !== null;
const hasOverrideEn = r.overrideEn !== null;
return (
<div key={r.key} className="rounded-md border border-zinc-100 bg-zinc-50/50 p-3">
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
<code className="text-[11px] font-mono text-zinc-600">{r.key}</code>
<span className="flex gap-1 text-[10px] uppercase tracking-wider">
{hasOverrideFr ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
FR modifié
</span>
) : null}
{hasOverrideEn ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
EN modifié
</span>
) : null}
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
FR
</span>
<input type="hidden" name={`entries[${idxFr}][key]`} value={r.key} />
<input type="hidden" name={`entries[${idxFr}][lang]`} value="fr" />
<input type="hidden" name={`entries[${idxFr}][baseValue]`} value={r.baseFr} />
<textarea
name={`entries[${idxFr}][value]`}
rows={autoRows(init.fr)}
defaultValue={init.fr}
maxLength={4000}
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
/>
<span className="mt-0.5 block text-[10px] text-zinc-400">
Base : <span className="italic">{r.baseFr.slice(0, 80)}{r.baseFr.length > 80 ? "…" : ""}</span>
</span>
</label>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
EN
</span>
<input type="hidden" name={`entries[${idxEn}][key]`} value={r.key} />
<input type="hidden" name={`entries[${idxEn}][lang]`} value="en" />
<input type="hidden" name={`entries[${idxEn}][baseValue]`} value={r.baseEn} />
<textarea
name={`entries[${idxEn}][value]`}
rows={autoRows(init.en)}
defaultValue={init.en}
maxLength={4000}
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
/>
<span className="mt-0.5 block text-[10px] text-zinc-400">
Base : <span className="italic">{r.baseEn.slice(0, 80)}{r.baseEn.length > 80 ? "…" : ""}</span>
</span>
</label>
</div>
</div>
);
})}
</div>
</section>
))}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="sticky bottom-3 flex items-center justify-end gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-md">
<span className="text-xs text-zinc-500">
Laisser une case vide ou identique au texte de base réinitialise l&apos;override.
</span>
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : "Enregistrer les modifications"}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { deleteTranslationOverride, upsertTranslation } from "@/lib/admin/translations";
import { invalidateTranslationCache } from "@/lib/i18n/overrides";
import { isHomeKey } from "@/lib/admin/home-keys";
const entrySchema = z.object({
key: z.string().min(1).max(200),
lang: z.enum(["fr", "en"]),
value: z.string().max(4000),
baseValue: z.string().max(4000),
});
type SaveResult = { ok: true; saved: number; reset: number } | { ok: false; error: string };
export async function saveHomeTranslationsAction(fd: FormData): Promise<SaveResult> {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
// FormData arrive avec entries[N][key], entries[N][lang], entries[N][value], entries[N][baseValue].
const grouped = new Map<string, Record<string, string>>();
for (const [name, val] of fd.entries()) {
if (typeof val !== "string") continue;
const m = name.match(/^entries\[(\d+)\]\[(key|lang|value|baseValue)\]$/);
if (!m) continue;
const [, idx, field] = m;
if (!grouped.has(idx)) grouped.set(idx, {});
grouped.get(idx)![field] = val;
}
let saved = 0;
let reset = 0;
for (const raw of grouped.values()) {
const parsed = entrySchema.safeParse(raw);
if (!parsed.success) continue;
if (!isHomeKey(parsed.data.key)) continue;
const trimmed = parsed.data.value.trim();
const base = parsed.data.baseValue;
if (trimmed === "" || trimmed === base) {
// Suppression de l'override : on revient à la valeur du fichier.
await deleteTranslationOverride(parsed.data.key, parsed.data.lang);
reset++;
} else {
await upsertTranslation(parsed.data.key, parsed.data.lang, trimmed, actorEmail);
saved++;
}
}
invalidateTranslationCache();
await recordAudit({
scope: "admin.home",
event: "translations.save",
actorEmail,
details: { saved, reset },
});
revalidatePath("/admin/home");
revalidatePath("/");
return { ok: true, saved, reset };
}

View file

@ -0,0 +1,39 @@
import { HOME_SECTIONS } from "@/lib/admin/home-keys";
import { listTranslationsForKeys } from "@/lib/admin/translations";
import { HomeTranslationsForm } from "./_components/HomeTranslationsForm";
export const dynamic = "force-dynamic";
export default async function HomeAdminPage() {
const allKeys = await listTranslationsForKeys(HOME_SECTIONS.flatMap((s) => s.prefixes));
const keysBySection = HOME_SECTIONS.map((s) => ({
id: s.id,
label: s.label,
description: s.description,
rows: allKeys.filter((r) => s.prefixes.some((p) => r.key.startsWith(p))),
}));
const totalOverrides = allKeys.reduce(
(acc, r) => acc + (r.overrideFr !== null ? 1 : 0) + (r.overrideEn !== null ? 1 : 0),
0,
);
return (
<div className="mx-auto max-w-6xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Page d&apos;accueil</h1>
<p className="mt-1 text-sm text-zinc-600">
Édition des textes affichés sur la page d&apos;accueil publique, en français et en anglais.
Les modifications sont appliquées immédiatement (cache rafraîchi sous 10 secondes).
</p>
<p className="mt-1 text-xs text-zinc-500">
{totalOverrides === 0
? "Aucun texte personnalisé pour l'instant — les valeurs par défaut viennent des fichiers de traduction."
: `${totalOverrides} valeur${totalOverrides > 1 ? "s" : ""} personnalisée${totalOverrides > 1 ? "s" : ""} actuellement active${totalOverrides > 1 ? "s" : ""}.`}
</p>
</header>
<HomeTranslationsForm sections={keysBySection} />
</div>
);
}

24
src/app/admin/layout.tsx Normal file
View file

@ -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 (
<div data-admin className="flex h-screen w-full overflow-hidden bg-zinc-50">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<TopBar userEmail={session.user.email ?? ""} />
<Breadcrumbs />
<main className="flex-1 overflow-y-auto px-4 pb-12 pt-3">{children}</main>
</div>
<CommandPalette />
</div>
);
}

View file

@ -0,0 +1,137 @@
import Link from "next/link";
import { MediaType } from "@/generated/prisma/enums";
import { getMediaStats, listMediaAdmin } from "@/lib/admin/media";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ q?: string; type?: string; carbetId?: string }>;
};
const TYPE_VALUES = new Set<string>([MediaType.PHOTO, MediaType.VIDEO]);
export default async function MediaAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
type: TYPE_VALUES.has(sp.type ?? "") ? (sp.type as MediaType) : undefined,
carbetId: sp.carbetId || undefined,
};
const [items, stats] = await Promise.all([listMediaAdmin(filters), getMediaStats()]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Médias</h1>
<p className="mt-1 text-sm text-zinc-500">
{items.length} affiché{items.length > 1 ? "s" : ""}
{items.length === 200 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</header>
<section className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat label="Total fichiers" value={stats.total} />
<Stat label="Photos" value={stats.photo} />
<Stat label="Vidéos" value={stats.video} />
<Stat label="Carbets avec média" value={stats.carbetsWithMedia} />
<Stat label="Carbets sans média" value={stats.carbetsWithoutMedia} tone="warn" />
</section>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche s3Key, carbet, slug…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="type"
defaultValue={filters.type ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Photos + vidéos</option>
<option value={MediaType.PHOTO}>Photos</option>
<option value={MediaType.VIDEO}>Vidéos</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.type || filters.carbetId) ? (
<Link href="/admin/media" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
{items.length === 0 ? (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500">
Aucun média ne correspond aux filtres.
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{items.map((m) => (
<Link
key={m.id}
href={`/admin/carbets/${m.carbet.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:shadow-md"
>
<div className="relative aspect-video bg-zinc-100">
{m.type === MediaType.PHOTO ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.s3Url}
alt={m.s3Key}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-3xl text-zinc-400"></div>
)}
<span className="absolute right-1 top-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
{m.type}
</span>
</div>
<div className="flex flex-col gap-1 p-2 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-zinc-900">{m.carbet.title}</span>
<StatusBadge status={m.carbet.status} />
</div>
<div className="flex items-center justify-between gap-2 text-[10px] text-zinc-500">
<code className="truncate">{m.s3Key}</code>
<span className="whitespace-nowrap">{dateFmt.format(m.createdAt)}</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
function Stat({
label,
value,
tone = "neutral",
}: {
label: string;
value: number;
tone?: "neutral" | "warn";
}) {
return (
<div
className={
"rounded-lg border bg-white p-3 shadow-sm " +
(tone === "warn" && value > 0 ? "border-amber-300" : "border-zinc-200")
}
>
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className={"mt-1 text-2xl font-semibold " + (tone === "warn" && value > 0 ? "text-amber-700" : "text-zinc-900")}>
{value}
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
memberCount: number;
};
export function DeleteOrgButton({ action, memberCount }: Props) {
const [pending, startTransition] = useTransition();
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState<string | null>(null);
function run() {
setError(null);
startTransition(async () => {
const res = await action();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirm(false);
}
});
}
if (memberCount > 0) {
return (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-xs text-zinc-500">
Suppression impossible {memberCount} membre{memberCount > 1 ? "s" : ""} rattaché{memberCount > 1 ? "s" : ""}
</span>
);
}
return (
<div className="flex flex-col items-end gap-1">
{confirm ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer définitivement ?</span>
<button
type="button"
onClick={run}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui, supprimer
</button>
<button
type="button"
onClick={() => setConfirm(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirm(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer l&apos;organisation
</button>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,90 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getOrganizationForAdmin } from "@/lib/admin/organizations";
import { OrgForm } from "../_components/OrgForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { deleteOrganizationAction, updateOrganizationAction } from "../actions";
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
export const dynamic = "force-dynamic";
const ROLE_LABEL: Record<string, string> = {
OWNER: "Propriétaire",
CE_MANAGER: "CE — Manager",
CE_MEMBER: "CE — Membre",
TOURIST: "Touriste",
ADMIN: "Admin",
};
type PageProps = { params: Promise<{ id: string }> };
export default async function EditOrgPage({ params }: PageProps) {
const { id } = await params;
const org = await getOrganizationForAdmin(id);
if (!org) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateOrganizationAction(id, fd);
};
const deleteThis = async () => {
"use server";
return await deleteOrganizationAction(id);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les organisations
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{org.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
</p>
</div>
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
<OrgForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{ name: org.name, slug: org.slug, description: org.description }}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Membres ({org.members.length})
</h2>
{org.members.length === 0 ? (
<p className="text-sm text-zinc-500">
Aucun membre. Rattachez un utilisateur via{" "}
<Link href="/admin/users" className="text-zinc-900 hover:underline">
la page Utilisateurs
</Link>
.
</p>
) : (
<ul className="divide-y divide-zinc-100">
{org.members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href={`/admin/users/${m.id}`} className="text-zinc-900 hover:underline">
{m.firstName} {m.lastName}
<span className="ml-2 text-[11px] text-zinc-500">{m.email}</span>
</Link>
<span className="flex items-center gap-2">
<span className="text-xs text-zinc-600">{ROLE_LABEL[m.role] ?? m.role}</span>
<StatusBadge status={m.isActive ? "ACTIVE" : "INACTIVE"} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
initial?: {
name?: string;
slug?: string;
description?: string | null;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function OrgForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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("Organisation enregistrée.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom" required>
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
</FormField>
<FormField label="Slug" required hint="URL : /organizations/<slug>">
<input
name="slug"
defaultValue={initial.slug ?? ""}
required
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
placeholder="ex. ce-airbus-kourou"
className={inputCls}
/>
</FormField>
</div>
<FormField label="Description" hint="Brève présentation interne (max 5000 caractères).">
<textarea
name="description"
rows={5}
defaultValue={initial.description ?? ""}
maxLength={5000}
className={textareaCls}
/>
</FormField>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,89 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
await recordAudit({ scope: "admin.organizations", event, target, actorEmail: actor, details });
}
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
const orgSchema = z.object({
name: z.string().trim().min(2).max(200),
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
description: z.string().trim().max(5000).optional().nullable(),
});
function parseFD(fd: FormData) {
return {
name: (fd.get("name") as string | null) ?? "",
slug: (fd.get("slug") as string | null) ?? "",
description: ((fd.get("description") as string | null) ?? "") || null,
};
}
export async function createOrganizationAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = orgSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
const created = await prisma.organization.create({
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
});
await audit("organization.create", created.id, session?.user?.email ?? null, { slug: created.slug });
revalidatePath("/admin/organizations");
} catch (e) {
if (e instanceof Error && e.message.includes("Unique")) {
return { ok: false as const, error: "Ce slug existe déjà." };
}
return { ok: false as const, error: "Erreur lors de la création." };
}
redirect("/admin/organizations");
}
export async function updateOrganizationAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = orgSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
await prisma.organization.update({
where: { id },
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
});
} catch (e) {
if (e instanceof Error && e.message.includes("Unique")) {
return { ok: false as const, error: "Ce slug est déjà pris." };
}
return { ok: false as const, error: "Erreur lors de la mise à jour." };
}
await audit("organization.update", id, session?.user?.email ?? null, { slug: parsed.data.slug });
revalidatePath("/admin/organizations");
revalidatePath(`/admin/organizations/${id}`);
return { ok: true as const };
}
export async function deleteOrganizationAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const count = await prisma.user.count({ where: { organizationId: id } });
if (count > 0) {
return { ok: false as const, error: `Impossible : ${count} membre(s) encore rattaché(s).` };
}
await prisma.organization.delete({ where: { id } });
await audit("organization.delete", id, session?.user?.email ?? null, {});
revalidatePath("/admin/organizations");
redirect("/admin/organizations");
}

View file

@ -0,0 +1,21 @@
import Link from "next/link";
import { OrgForm } from "../_components/OrgForm";
import { createOrganizationAction } from "../actions";
export const dynamic = "force-dynamic";
export default function NewOrgPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les organisations
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvelle organisation</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<OrgForm action={createOrganizationAction} submitLabel="Créer l'organisation" />
</section>
</div>
);
}

View file

@ -0,0 +1,89 @@
import Link from "next/link";
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ q?: string }>;
};
export default async function OrgsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = { q: sp.q?.trim() || undefined };
const orgs = await listOrganizationsAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Organisations CE</h1>
<p className="mt-1 text-sm text-zinc-500">
{orgs.length} résultat{orgs.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/organizations/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouvelle organisation
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, slug, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{filters.q ? (
<Link href="/admin/organizations" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Slug</th>
<th className="px-4 py-2 text-right font-semibold">Membres</th>
<th className="px-4 py-2 text-right font-semibold">Créée</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{orgs.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune organisation.
</td>
</tr>
) : null}
{orgs.map((o) => (
<tr key={o.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/organizations/${o.id}`} className="font-medium text-zinc-900 hover:underline">
{o.name}
</Link>
{o.description ? (
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
) : null}
</td>
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,14 +1,103 @@
import { requireRole } from "@/lib/authorization"; import Link from "next/link";
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
import { KPICard } from "@/components/admin/KPICard";
export default async function AdminPage() { export const dynamic = "force-dynamic";
const session = await requireRole(["ADMIN"]);
export default async function AdminDashboard() {
const kpis = await getAdminKpis();
return ( return (
<main className="mx-auto max-w-4xl px-6 py-12"> <div className="mx-auto max-w-6xl">
<h1 className="text-3xl font-semibold">Espace administrateur</h1> <header className="mb-6 mt-2">
<p className="mt-4 text-zinc-700"> <h1 className="text-2xl font-semibold text-zinc-900">Tableau de bord</h1>
Accès autorisé pour {session.user.email} ({session.user.role}). <p className="mt-1 text-sm text-zinc-500">
</p> Vue d&apos;ensemble de l&apos;activité Karbé. Données live (cache 0).
</main> </p>
</header>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<KPICard
label="Réservations cette semaine"
value={kpis.bookingsThisWeek}
hint="Toutes statuts confondus, démarrage dans la semaine en cours."
tone="info"
/>
<KPICard
label="Réservations confirmées · 30 j"
value={kpis.bookingsConfirmed30d}
hint="CONFIRMED + paiement SUCCEEDED, démarrage J-30."
tone="ok"
/>
<KPICard
label="Revenus reversés · 30 j"
value={formatEur(kpis.revenue30dCents)}
hint="Somme des montants confirmés (reversement loueurs)."
tone="ok"
/>
<KPICard
label="Occupation moyenne · 30 j"
value={`${kpis.occupancyPct} %`}
hint="Nuits réservées / (carbets publiés × 30)."
tone={kpis.occupancyPct > 50 ? "ok" : "neutral"}
/>
<KPICard
label="Nouveaux comptes · 30 j"
value={kpis.newUsers30d}
hint="Inscriptions tous rôles confondus."
tone="info"
/>
<KPICard
label="Carbets publiés"
value={kpis.publishedCarbets}
hint="Catalogue actif (status PUBLISHED)."
tone="neutral"
/>
<KPICard
label="Avis à modérer"
value={kpis.reviewsToModerate}
hint="Aucune réponse de l&apos;hôte enregistrée."
tone={kpis.reviewsToModerate > 5 ? "warn" : "neutral"}
/>
</section>
<section className="mt-10 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
Raccourcis fréquents
</h2>
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
<li>
<Link href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Gérer les carbets
</Link>
</li>
<li>
<Link href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Voir les réservations
</Link>
</li>
<li>
<Link href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Éditer les pages
</Link>
</li>
<li>
<Link href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Activer / désactiver des plugins
</Link>
</li>
<li>
<Link href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Modérer les utilisateurs
</Link>
</li>
<li>
<Link href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Paramètres
</Link>
</li>
</ul>
</section>
</div>
); );
} }

View file

@ -0,0 +1,98 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Props = {
active: boolean;
carbetsCount: number;
toggleAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ProviderInlineActions({ active, carbetsCount, toggleAction, deleteAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle() {
setError(null);
startTransition(async () => {
const res = await toggleAction(!active);
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
}
router.refresh();
});
}
function del() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirmDelete(false);
}
});
}
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{carbetsCount === 0 ? (
confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui, supprimer
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
)
) : (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
Suppression impossible {carbetsCount} carbet{carbetsCount > 1 ? "s" : ""} rattaché{carbetsCount > 1 ? "s" : ""}
</span>
)}
</div>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,105 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getPirogueProviderForAdmin } from "@/lib/admin/pirogue-providers";
import { ProviderForm } from "../_components/ProviderForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import {
deletePirogueProviderAction,
togglePirogueActiveAction,
updatePirogueProviderAction,
} from "../actions";
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function EditPirogueProviderPage({ params }: PageProps) {
const { id } = await params;
const p = await getPirogueProviderForAdmin(id);
if (!p) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updatePirogueProviderAction(id, fd);
};
const toggleThis = async (active: boolean) => {
"use server";
return await togglePirogueActiveAction(id, active);
};
const deleteThis = async () => {
"use server";
return await deletePirogueProviderAction(id);
};
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/pirogue-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{p.name}
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
Fleuves : {p.rivers.length === 0 ? "—" : p.rivers.join(", ")} · {p.carbets.length} carbet
{p.carbets.length > 1 ? "s" : ""} référencé{p.carbets.length > 1 ? "s" : ""}
</p>
</div>
<ProviderInlineActions
active={p.active}
carbetsCount={p.carbets.length}
toggleAction={toggleThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
<ProviderForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
name: p.name,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
rivers: p.rivers,
pricingNote: p.pricingNote,
description: p.description,
active: p.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Carbets référencés ({p.carbets.length})
</h2>
{p.carbets.length === 0 ? (
<p className="text-sm text-zinc-500">Aucun carbet ne référence ce prestataire pour le moment.</p>
) : (
<ul className="divide-y divide-zinc-100">
{p.carbets.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href={`/admin/carbets/${c.id}`} className="text-zinc-900 hover:underline">
{c.title}
<span className="ml-2 text-[11px] text-zinc-500">
<code>/{c.slug}</code> · {c.river}
</span>
</Link>
<span className="flex items-center gap-2">
<StatusBadge status={c.status} />
<span className="text-[11px] text-zinc-500">{dateFmt.format(c.updatedAt)}</span>
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,119 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
initial?: {
name?: string;
contactEmail?: string | null;
contactPhone?: string | null;
rivers?: string[];
pricingNote?: string | null;
description?: string | null;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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("Prestataire enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom" required>
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
</FormField>
<FormField label="Email de contact">
<input
name="contactEmail"
type="email"
defaultValue={initial.contactEmail ?? ""}
maxLength={200}
className={inputCls}
/>
</FormField>
<FormField label="Téléphone de contact">
<input
name="contactPhone"
defaultValue={initial.contactPhone ?? ""}
maxLength={50}
className={inputCls}
/>
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="active"
defaultChecked={initial.active ?? true}
className="h-4 w-4 rounded border-zinc-300"
/>
Prestataire actif (sélectionnable sur un carbet)
</label>
</FormField>
</div>
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
<input
name="rivers"
defaultValue={(initial.rivers ?? []).join(", ")}
placeholder="Maroni, Approuague, Oyapock"
className={inputCls}
/>
</FormField>
<FormField label="Tarification" hint="Note libre — fourchette de prix, conditions, durées.">
<textarea
name="pricingNote"
rows={3}
defaultValue={initial.pricingNote ?? ""}
maxLength={2000}
className={textareaCls}
/>
</FormField>
<FormField label="Description" hint="Présentation, langues parlées, prestations annexes.">
<textarea
name="description"
rows={4}
defaultValue={initial.description ?? ""}
maxLength={5000}
className={textareaCls}
/>
</FormField>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,95 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
await recordAudit({ scope: "admin.pirogue", event, target, actorEmail: actor, details });
}
const providerSchema = z.object({
name: z.string().trim().min(2).max(200),
contactEmail: z.string().trim().email().max(200).optional().nullable(),
contactPhone: z.string().trim().max(50).optional().nullable(),
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
pricingNote: z.string().trim().max(2000).optional().nullable(),
description: z.string().trim().max(5000).optional().nullable(),
active: z.boolean(),
});
function parseFD(fd: FormData) {
const riversRaw = (fd.get("rivers") as string | null) ?? "";
const rivers = riversRaw
.split(/[,;\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
name: ((fd.get("name") as string | null) ?? "").trim(),
contactEmail: get("contactEmail"),
contactPhone: get("contactPhone"),
rivers,
pricingNote: get("pricingNote"),
description: get("description"),
active: fd.get("active") === "on",
};
}
export async function createPirogueProviderAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = providerSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const created = await prisma.pirogueProvider.create({ data: parsed.data });
await audit("pirogue.create", created.id, session?.user?.email ?? null, { name: created.name });
revalidatePath("/admin/pirogue-providers");
redirect(`/admin/pirogue-providers/${created.id}`);
}
export async function updatePirogueProviderAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = providerSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
await prisma.pirogueProvider.update({ where: { id }, data: parsed.data });
await audit("pirogue.update", id, session?.user?.email ?? null, { name: parsed.data.name, active: parsed.data.active });
revalidatePath("/admin/pirogue-providers");
revalidatePath(`/admin/pirogue-providers/${id}`);
return { ok: true as const };
}
export async function togglePirogueActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.pirogueProvider.update({ where: { id }, data: { active } });
await audit("pirogue.active.update", id, session?.user?.email ?? null, { active });
revalidatePath("/admin/pirogue-providers");
revalidatePath(`/admin/pirogue-providers/${id}`);
return { ok: true as const };
}
export async function deletePirogueProviderAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const count = await prisma.carbet.count({ where: { pirogueProviderId: id } });
if (count > 0) {
return { ok: false as const, error: `Impossible : ${count} carbet(s) référencent ce prestataire.` };
}
await prisma.pirogueProvider.delete({ where: { id } });
await audit("pirogue.delete", id, session?.user?.email ?? null, {});
revalidatePath("/admin/pirogue-providers");
redirect("/admin/pirogue-providers");
}

View file

@ -0,0 +1,21 @@
import Link from "next/link";
import { ProviderForm } from "../_components/ProviderForm";
import { createPirogueProviderAction } from "../actions";
export const dynamic = "force-dynamic";
export default function NewPirogueProviderPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/pirogue-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire pirogue</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ProviderForm action={createPirogueProviderAction} submitLabel="Créer le prestataire" />
</section>
</div>
);
}

View file

@ -0,0 +1,124 @@
import Link from "next/link";
import { listPirogueProvidersAdmin, listPirogueRivers } from "@/lib/admin/pirogue-providers";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
river?: string;
active?: string;
}>;
};
export default async function PirogueProvidersAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
river: sp.river || undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
};
const [rows, rivers] = await Promise.all([listPirogueProvidersAdmin(filters), listPirogueRivers()]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires pirogue</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/pirogue-providers/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouveau prestataire
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, email, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.river || filters.active) ? (
<Link href="/admin/pirogue-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
<th className="px-4 py-2 text-left font-semibold">Contact</th>
<th className="px-4 py-2 text-right font-semibold">Carbets</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun prestataire ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((p) => (
<tr key={p.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/pirogue-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
{p.name}
</Link>
</td>
<td className="px-4 py-2 text-zinc-700">
{p.rivers.length === 0 ? <span className="text-zinc-400"></span> : p.rivers.join(", ")}
</td>
<td className="px-4 py-2 text-[11px] text-zinc-600">
{p.contactEmail ? <div>{p.contactEmail}</div> : null}
{p.contactPhone ? <div>{p.contactPhone}</div> : null}
{!p.contactEmail && !p.contactPhone ? <span className="text-zinc-400"></span> : null}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.carbetsCount}</td>
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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<string, unknown>;
}
const CATEGORY_LABEL: Record<string, string> = {
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const byCategory = plugins.reduce<Record<string, PluginRow[]>>((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 (
<div className="space-y-8">
{error && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{Object.entries(byCategory).map(([category, rows]) => (
<section key={category}>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
{CATEGORY_LABEL[category] ?? category}
</h2>
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{rows.map((p) => (
<li key={p.key} className="flex items-start justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{p.name}</span>
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">{p.key}</code>
<span className="text-xs text-gray-400">v{p.version}</span>
</div>
<p className="mt-1 text-sm text-gray-600">{p.description}</p>
</div>
<button
type="button"
onClick={() => toggle(p.key, !p.enabled)}
disabled={pending || busyKey === p.key}
className={`shrink-0 rounded-full px-3 py-1 text-xs font-semibold transition ${
p.enabled
? "bg-green-600 text-white hover:bg-green-700"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
} disabled:opacity-50`}
>
{busyKey === p.key ? "…" : p.enabled ? "Activé" : "Désactivé"}
</button>
</li>
))}
</ul>
</section>
))}
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold">Plugins Karbé</h1>
<p className="mt-2 text-sm text-gray-600">
Active ou désactive chaque module. Les changements prennent effet immédiatement (cache 5 s).
L&apos;onEnable/onDisable est exécuté avant la bascule.
</p>
<div className="mt-6">
<PluginToggleTable plugins={plugins} />
</div>
</div>
);
}

View file

@ -0,0 +1,86 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Props = {
active: boolean;
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle() {
setError(null);
startTransition(async () => {
const res = await toggleActiveAction(!active);
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
router.refresh();
});
}
function del() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirmDelete(false);
}
});
}
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
)}
</div>
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
</div>
);
}

View file

@ -0,0 +1,92 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaUploader } from "@/components/MediaUploader";
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
import { ItemForm } from "../_components/ItemForm";
import { ItemInlineActions } from "./_components/ItemInlineActions";
import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function EditRentalItemPage({ params }: PageProps) {
const { id } = await params;
const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
if (!item) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateRentalItemAction(id, fd);
};
const toggleActiveThis = async (active: boolean) => {
"use server";
return await toggleRentalItemActiveAction(id, active);
};
const deleteThis = async () => {
"use server";
return await deleteRentalItemAction(id);
};
return (
<div className="mx-auto max-w-4xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les items
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{item.name}
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
{item.provider.name}
</Link>
{item.provider.isSystemD ? " (System D)" : ""}
</p>
</div>
<ItemInlineActions
active={item.active}
toggleActiveAction={toggleActiveThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
<MediaUploader
scope={{ kind: "rental-item", itemId: item.id }}
initialMedia={item.media}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
providerId: item.providerId,
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</section>
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
type Props = {
providers: { id: string; name: string; isSystemD: boolean }[];
initial?: {
providerId?: string;
category?: string;
name?: string;
description?: string | null;
imageUrl?: string | null;
pricePerDay?: string | number;
pricePerWeek?: string | number | null;
deposit?: string | number;
totalQty?: number;
withMotor?: boolean;
fuelIncluded?: boolean;
requiresLicense?: boolean;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Prestataire" required>
<select name="providerId" defaultValue={initial.providerId ?? ""} required className={selectCls}>
<option value="" disabled> sélectionner </option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isSystemD ? " (System D)" : ""}
</option>
))}
</select>
</FormField>
<FormField label="Catégorie" required>
<select name="category" defaultValue={initial.category ?? ""} required className={selectCls}>
<option value="" disabled> sélectionner </option>
{RENTAL_CATEGORIES.map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
</FormField>
<FormField label="Nom de l'item" required className="sm:col-span-2">
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} placeholder="ex. Hamac coton large, Pirogue 5m avec moteur 15CV" />
</FormField>
<FormField label="Description" className="sm:col-span-2">
<textarea name="description" rows={3} defaultValue={initial.description ?? ""} maxLength={5000} className={textareaCls} />
</FormField>
<FormField label="URL image" hint="Optionnel, URL publique vers photo MinIO.">
<input name="imageUrl" type="url" defaultValue={initial.imageUrl ?? ""} maxLength={500} className={inputCls} />
</FormField>
<FormField label="Stock total (qté)" required>
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
</FormField>
<FormField label="Prix / jour (€)" required>
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
</FormField>
<FormField label="Prix / semaine (€)" hint="Optionnel — tarif dégressif sur 7+ jours.">
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
</FormField>
<FormField label="Caution (€)" hint="Dépôt de garantie (bloqué pendant la location).">
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
Actif (visible au catalogue)
</label>
</FormField>
</div>
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
Spécifications navigation
</legend>
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
Avec moteur
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
Essence incluse
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
Permis bateau requis
</label>
</div>
</fieldset>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,129 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { RentalCategory, UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const itemSchema = z.object({
providerId: z.string().min(1),
category: z.enum([
RentalCategory.SLEEP,
RentalCategory.NAVIGATION,
RentalCategory.FISHING,
RentalCategory.COOKING,
RentalCategory.SAFETY,
]),
name: z.string().trim().min(2).max(200),
description: z.string().trim().max(5000).nullable().optional(),
imageUrl: z.string().trim().url().max(500).nullable().optional(),
pricePerDay: z.coerce.number().min(0).max(10000),
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
deposit: z.coerce.number().min(0).max(10000),
totalQty: z.coerce.number().int().min(1).max(1000),
withMotor: z.boolean(),
fuelIncluded: z.boolean(),
requiresLicense: z.boolean(),
active: z.boolean(),
});
function parseFD(fd: FormData) {
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
category: ((fd.get("category") as string | null) ?? "").trim(),
name: ((fd.get("name") as string | null) ?? "").trim(),
description: get("description"),
imageUrl: get("imageUrl"),
pricePerDay: fd.get("pricePerDay"),
pricePerWeek: get("pricePerWeek"),
deposit: fd.get("deposit") ?? "0",
totalQty: fd.get("totalQty") ?? "1",
withMotor: fd.get("withMotor") === "on",
fuelIncluded: fd.get("fuelIncluded") === "on",
requiresLicense: fd.get("requiresLicense") === "on",
active: fd.get("active") === "on",
};
}
export async function createRentalItemAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = itemSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const created = await prisma.rentalItem.create({ data: parsed.data });
await recordAudit({
scope: "admin.rental-items",
event: "create",
target: created.id,
actorEmail: session?.user?.email ?? null,
details: { name: created.name, providerId: created.providerId },
});
revalidatePath("/admin/rental-items");
redirect(`/admin/rental-items/${created.id}`);
}
export async function updateRentalItemAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = itemSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
await prisma.rentalItem.update({ where: { id }, data: parsed.data });
await recordAudit({
scope: "admin.rental-items",
event: "update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { name: parsed.data.name },
});
revalidatePath("/admin/rental-items");
revalidatePath(`/admin/rental-items/${id}`);
return { ok: true as const };
}
export async function toggleRentalItemActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.rentalItem.update({ where: { id }, data: { active } });
await recordAudit({
scope: "admin.rental-items",
event: "active.update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { active },
});
revalidatePath("/admin/rental-items");
revalidatePath(`/admin/rental-items/${id}`);
return { ok: true as const };
}
export async function deleteRentalItemAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
if (linesCount > 0) {
return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
}
await prisma.rentalItem.delete({ where: { id } });
await recordAudit({
scope: "admin.rental-items",
event: "delete",
target: id,
actorEmail: session?.user?.email ?? null,
details: {},
});
revalidatePath("/admin/rental-items");
redirect("/admin/rental-items");
}

View file

@ -0,0 +1,31 @@
import Link from "next/link";
import { ItemForm } from "../_components/ItemForm";
import { createRentalItemAction } from "../actions";
import { listProvidersForSelect } from "@/lib/admin/rental-items";
export const dynamic = "force-dynamic";
type PageProps = { searchParams: Promise<{ providerId?: string }> };
export default async function NewRentalItemPage({ searchParams }: PageProps) {
const sp = await searchParams;
const providers = await listProvidersForSelect();
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item locable</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}
action={createRentalItemAction}
submitLabel="Créer l'item"
initial={{ providerId: sp.providerId, active: true, totalQty: 1 }}
/>
</section>
</div>
);
}

View file

@ -0,0 +1,152 @@
import Link from "next/link";
import { RentalCategory } from "@/generated/prisma/enums";
import {
RENTAL_CATEGORY_LABEL,
isRentalCategory,
listProvidersForSelect,
listRentalItemsAdmin,
} from "@/lib/admin/rental-items";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
category?: string;
providerId?: string;
active?: string;
}>;
};
export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
providerId: sp.providerId || undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
};
const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Catalogue d&apos;items locables</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} item{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/rental-items/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouvel item
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="category"
defaultValue={filters.category ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Toutes catégories</option>
{Object.values(RentalCategory).map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
<select
name="providerId"
defaultValue={filters.providerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous prestataires</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.category || filters.providerId || filters.active) ? (
<Link href="/admin/rental-items" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
<th className="px-4 py-2 text-right font-semibold"> / jour</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun item.
</td>
</tr>
) : null}
{rows.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/rental-items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence · " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${i.providerId}`} className="text-zinc-900 hover:underline">
{i.providerName}
</Link>
{i.providerIsSystemD ? (
<span className="ml-1 rounded-full bg-emerald-100 px-1 py-0 text-[9px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
SD
</span>
) : null}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
<td className="px-4 py-2"><StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(i.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Props = {
approved: boolean;
active: boolean;
itemsCount: number;
approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ProviderInlineActions({
approved,
active,
itemsCount,
approveAction,
toggleActiveAction,
deleteAction,
}: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
function approve() {
setError(null);
startTransition(async () => {
const res = await approveAction();
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
router.refresh();
});
}
function toggle() {
setError(null);
startTransition(async () => {
const res = await toggleActiveAction(!active);
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
router.refresh();
});
}
function del() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirmDelete(false);
}
});
}
return (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
{!approved ? (
<button
type="button"
onClick={approve}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Approuver
</button>
) : null}
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{itemsCount === 0 ? (
confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
)
) : (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
{itemsCount} item(s) supprimez-les d&apos;abord
</span>
)}
</div>
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
</div>
);
}

View file

@ -0,0 +1,136 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
import { ProviderForm } from "../_components/ProviderForm";
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
import {
approveRentalProviderAction,
deleteRentalProviderAction,
toggleRentalProviderActiveAction,
updateRentalProviderAction,
} from "../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function EditRentalProviderPage({ params }: PageProps) {
const { id } = await params;
const p = await getRentalProviderForAdmin(id);
if (!p) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateRentalProviderAction(id, fd);
};
const approveThis = async () => {
"use server";
return await approveRentalProviderAction(id);
};
const toggleActiveThis = async (active: boolean) => {
"use server";
return await toggleRentalProviderActiveAction(id, active);
};
const deleteThis = async () => {
"use server";
return await deleteRentalProviderAction(id);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{p.name}
{p.isSystemD ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
System D
</span>
) : null}
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
{p.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Approuvé
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
En attente
</span>
)}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Fleuves : {p.rivers.join(", ") || "—"} · {p._count.items} item(s) · {p._count.rentalBookings} réservation(s) · Commission {Number(p.commissionPct).toFixed(1)}%
</p>
</div>
<ProviderInlineActions
approved={p.approved}
active={p.active}
itemsCount={p._count.items}
approveAction={approveThis}
toggleActiveAction={toggleActiveThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
<ProviderForm
action={updateThis}
submitLabel="Enregistrer"
initial={{
name: p.name,
isSystemD: p.isSystemD,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
rivers: p.rivers,
description: p.description,
commissionPct: p.commissionPct.toString(),
active: p.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 flex items-center justify-between text-sm font-semibold uppercase tracking-wider text-zinc-500">
<span>Items ({p.items.length})</span>
<Link href={`/admin/rental-items?providerId=${p.id}`} className="text-xs normal-case tracking-normal text-zinc-700 underline hover:text-zinc-900">
Voir tous les items
</Link>
</h2>
{p.items.length === 0 ? (
<p className="text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href={`/admin/rental-items/new?providerId=${p.id}`} className="text-zinc-900 underline">
Créer un premier item
</Link>
</p>
) : (
<ul className="divide-y divide-zinc-100">
{p.items.map((i) => (
<li key={i.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href={`/admin/rental-items/${i.id}`} className="text-zinc-900 hover:underline">
{i.name}
<span className="ml-2 text-[11px] text-zinc-500">
{RENTAL_CATEGORY_LABEL[i.category]}
</span>
</Link>
<span className="flex items-center gap-3">
<span className="font-mono text-xs text-zinc-700">{Number(i.pricePerDay).toFixed(0)} /j</span>
<span className="text-[11px] text-zinc-500">qty {i.totalQty}</span>
<StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
initial?: {
name?: string;
isSystemD?: boolean;
contactEmail?: string | null;
contactPhone?: string | null;
rivers?: string[];
description?: string | null;
commissionPct?: number | string;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom du prestataire" required>
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
</FormField>
<FormField label="Type">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="isSystemD"
defaultChecked={initial.isSystemD ?? false}
className="h-4 w-4 rounded border-zinc-300"
/>
Fournisseur officiel System D (0 % commission)
</label>
</FormField>
<FormField label="Email contact">
<input
name="contactEmail"
type="email"
defaultValue={initial.contactEmail ?? ""}
maxLength={200}
className={inputCls}
/>
</FormField>
<FormField label="Téléphone contact">
<input
name="contactPhone"
defaultValue={initial.contactPhone ?? ""}
maxLength={50}
className={inputCls}
/>
</FormField>
<FormField label="Commission (%)" hint="0 pour System D, 5-15 % pour les prestataires externes.">
<input
name="commissionPct"
type="number"
min={0}
max={50}
step="0.5"
defaultValue={initial.commissionPct?.toString() ?? "10"}
className={inputCls}
/>
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="active"
defaultChecked={initial.active ?? true}
className="h-4 w-4 rounded border-zinc-300"
/>
Actif
</label>
</FormField>
</div>
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
<input
name="rivers"
defaultValue={(initial.rivers ?? []).join(", ")}
placeholder="Maroni, Approuague, Oyapock"
className={inputCls}
/>
</FormField>
<FormField label="Description" hint="Présentation, points forts, conditions particulières.">
<textarea
name="description"
rows={4}
defaultValue={initial.description ?? ""}
maxLength={5000}
className={textareaCls}
/>
</FormField>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,150 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const providerSchema = z.object({
name: z.string().trim().min(2).max(200),
isSystemD: z.boolean(),
managedByUserId: z.string().nullable().optional(),
contactEmail: z.string().trim().email().max(200).nullable().optional(),
contactPhone: z.string().trim().max(50).nullable().optional(),
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
description: z.string().trim().max(5000).nullable().optional(),
commissionPct: z.coerce.number().min(0).max(50),
active: z.boolean(),
});
function parseFD(fd: FormData) {
const riversRaw = (fd.get("rivers") as string | null) ?? "";
const rivers = riversRaw
.split(/[,;\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
name: ((fd.get("name") as string | null) ?? "").trim(),
isSystemD: fd.get("isSystemD") === "on",
managedByUserId: get("managedByUserId"),
contactEmail: get("contactEmail"),
contactPhone: get("contactPhone"),
rivers,
description: get("description"),
commissionPct: fd.get("commissionPct"),
active: fd.get("active") === "on",
};
}
export async function createRentalProviderAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = providerSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const created = await prisma.rentalProvider.create({
data: {
...parsed.data,
approved: true, // créé par admin → approuvé d'office
approvedAt: new Date(),
approvedBy: session?.user?.email ?? null,
},
});
await recordAudit({
scope: "admin.rental-providers",
event: "create",
target: created.id,
actorEmail: session?.user?.email ?? null,
details: { name: created.name, isSystemD: created.isSystemD },
});
revalidatePath("/admin/rental-providers");
redirect(`/admin/rental-providers/${created.id}`);
}
export async function updateRentalProviderAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = providerSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
await recordAudit({
scope: "admin.rental-providers",
event: "update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { name: parsed.data.name },
});
revalidatePath("/admin/rental-providers");
revalidatePath(`/admin/rental-providers/${id}`);
return { ok: true as const };
}
export async function approveRentalProviderAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.rentalProvider.update({
where: { id },
data: {
approved: true,
approvedAt: new Date(),
approvedBy: session?.user?.email ?? null,
},
});
await recordAudit({
scope: "admin.rental-providers",
event: "approve",
target: id,
actorEmail: session?.user?.email ?? null,
details: {},
});
revalidatePath("/admin/rental-providers");
revalidatePath(`/admin/rental-providers/${id}`);
return { ok: true as const };
}
export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.rentalProvider.update({ where: { id }, data: { active } });
await recordAudit({
scope: "admin.rental-providers",
event: "active.update",
target: id,
actorEmail: session?.user?.email ?? null,
details: { active },
});
revalidatePath("/admin/rental-providers");
revalidatePath(`/admin/rental-providers/${id}`);
return { ok: true as const };
}
export async function deleteRentalProviderAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
if (itemsCount > 0) {
return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
}
await prisma.rentalProvider.delete({ where: { id } });
await recordAudit({
scope: "admin.rental-providers",
event: "delete",
target: id,
actorEmail: session?.user?.email ?? null,
details: {},
});
revalidatePath("/admin/rental-providers");
redirect("/admin/rental-providers");
}

View file

@ -0,0 +1,21 @@
import Link from "next/link";
import { ProviderForm } from "../_components/ProviderForm";
import { createRentalProviderAction } from "../actions";
export const dynamic = "force-dynamic";
export default function NewRentalProviderPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire location</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ProviderForm action={createRentalProviderAction} submitLabel="Créer le prestataire" />
</section>
</div>
);
}

View file

@ -0,0 +1,149 @@
import Link from "next/link";
import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
approved?: string;
active?: string;
river?: string;
}>;
};
export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
river: sp.river || undefined,
};
const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires location matériel</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/rental-providers/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouveau prestataire
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, email, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="approved"
defaultValue={filters.approved ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous statuts approbation</option>
<option value="yes">Approuvés</option>
<option value="no">En attente</option>
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.approved || filters.active || filters.river) ? (
<Link href="/admin/rental-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
<th className="px-4 py-2 text-right font-semibold">Items</th>
<th className="px-4 py-2 text-right font-semibold">Comm.</th>
<th className="px-4 py-2 text-left font-semibold">Approbation</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun prestataire ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((p) => (
<tr key={p.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
{p.name}
</Link>
{p.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
System D
</span>
) : null}
<div className="text-[11px] text-zinc-500">{p.contactEmail ?? "—"}</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{p.rivers.length === 0 ? <span className="text-zinc-400"></span> : p.rivers.join(", ")}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.itemsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(p.commissionPct).toFixed(1)}%</td>
<td className="px-4 py-2">
{p.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Approuvé
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
En attente
</span>
)}
</td>
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,141 @@
import Link from "next/link";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
status?: string;
paymentStatus?: string;
providerId?: string;
}>;
};
const RENTAL_STATUS_VALUES = new Set<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
const PAYMENT_VALUES = new Set<string>([
PaymentStatus.PENDING,
PaymentStatus.AUTHORIZED,
PaymentStatus.SUCCEEDED,
PaymentStatus.FAILED,
PaymentStatus.REFUNDED,
]);
export default async function RentalsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
providerId: sp.providerId || undefined,
};
const rows = await listRentalBookingsAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Réservations matériel</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche ID, email locataire, prestataire…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select name="status" defaultValue={filters.status ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
))}
</select>
<select name="paymentStatus" defaultValue={filters.paymentStatus ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
<option value="">Tous paiements</option>
{Object.values(PaymentStatus).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.status || filters.paymentStatus) ? (
<Link href="/admin/rentals" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">ID</th>
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
<th className="px-4 py-2 text-left font-semibold">Items</th>
<th className="px-4 py-2 text-left font-semibold">Période</th>
<th className="px-4 py-2 text-right font-semibold">Montant</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</td>
</tr>
) : null}
{rows.map((r) => (
<tr key={r.id} className="hover:bg-zinc-50">
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">{r.id.slice(0, 10)}</td>
<td className="px-4 py-2 text-zinc-700">
{r.tenant.firstName} {r.tenant.lastName}
<div className="text-[11px] text-zinc-500">{r.tenant.email}</div>
</td>
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${r.provider.id}`} className="text-zinc-900 hover:underline">
{r.provider.name}
</Link>
{r.provider.isSystemD ? <span className="ml-1 text-[9px] font-semibold text-emerald-700">SD</span> : null}
</td>
<td className="px-4 py-2 text-zinc-700">
{r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
<div className="text-[11px] text-zinc-500 truncate max-w-[200px]">
{r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{dateFmt.format(r.startDate)} {dateFmt.format(r.endDate)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900">
{Number(r.amount).toFixed(2)} {r.currency}
</td>
<td className="px-4 py-2">
<StatusBadge status={r.status} />
</td>
<td className="px-4 py-2">
<StatusBadge status={r.paymentStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteReviewAction, updateReviewAction } from "../../actions";
import { inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
id: string;
initial: {
rating: number;
comment: string | null;
hostResponse: string | null;
};
};
export function ReviewForm({ id, initial }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
function onSubmit(formData: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await updateReviewAction(id, formData);
if (res && res.ok === false) {
setError(res.error);
} else {
setSuccess("Avis enregistré.");
router.refresh();
}
});
}
function onDelete() {
setError(null);
startTransition(async () => {
await deleteReviewAction(id);
router.push("/admin/reviews");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="flex items-center gap-3">
<label className="text-[11px] uppercase tracking-wider text-zinc-500">Note</label>
<select name="rating" defaultValue={String(initial.rating)} className={inputCls + " w-24"}>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={String(n)}>{n} </option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-[11px] uppercase tracking-wider text-zinc-500">
Commentaire du voyageur
</label>
<textarea
name="comment"
rows={5}
defaultValue={initial.comment ?? ""}
maxLength={5000}
className={textareaCls}
placeholder="(vide pour supprimer le commentaire)"
/>
</div>
<div>
<label className="mb-1 block text-[11px] uppercase tracking-wider text-zinc-500">
Réponse de l&apos;hôte
</label>
<textarea
name="hostResponse"
rows={4}
defaultValue={initial.hostResponse ?? ""}
maxLength={5000}
className={textareaCls}
placeholder="(vide pour supprimer la réponse)"
/>
</div>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-between gap-2">
{confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer définitivement ?</span>
<button
type="button"
onClick={onDelete}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui, supprimer
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer l&apos;avis
</button>
)}
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : "Enregistrer"}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,52 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getReviewForAdmin } from "@/lib/admin/reviews";
import { ReviewForm } from "./_components/ReviewForm";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function ReviewDetailPage({ params }: PageProps) {
const { id } = await params;
const review = await getReviewForAdmin(id);
if (!review) notFound();
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/reviews" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les avis
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">
Avis de {review.author.firstName} {review.author.lastName}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Sur{" "}
<Link href={`/admin/carbets/${review.carbet.id}`} className="text-zinc-900 hover:underline">
{review.carbet.title}
</Link>{" "}
· réservation{" "}
<Link href={`/admin/bookings/${review.booking.id}`} className="font-mono text-zinc-900 hover:underline">
{review.booking.id.slice(0, 12)}
</Link>{" "}
· publié le {dateFmt.format(review.createdAt)}
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Modération</h2>
<ReviewForm
id={review.id}
initial={{
rating: review.rating,
comment: review.comment,
hostResponse: review.hostResponse,
}}
/>
</section>
</div>
);
}

View file

@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
await recordAudit({ scope: "admin.reviews", event, target, actorEmail: actor, details });
}
const updateSchema = z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().trim().max(5000).optional().nullable(),
hostResponse: z.string().trim().max(5000).optional().nullable(),
});
export async function updateReviewAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const obj = Object.fromEntries(fd.entries());
const parsed = updateSchema.safeParse({
rating: obj.rating,
comment: obj.comment === "" ? null : obj.comment,
hostResponse: obj.hostResponse === "" ? null : obj.hostResponse,
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const current = await prisma.review.findUnique({ where: { id }, select: { hostResponse: true, hostRespondedAt: true } });
const hostRespondedAt =
parsed.data.hostResponse && parsed.data.hostResponse !== current?.hostResponse
? new Date()
: current?.hostRespondedAt ?? null;
await prisma.review.update({
where: { id },
data: {
rating: parsed.data.rating,
comment: parsed.data.comment ?? null,
hostResponse: parsed.data.hostResponse ?? null,
hostRespondedAt: parsed.data.hostResponse ? hostRespondedAt : null,
},
});
await audit("review.update", id, session?.user?.email ?? null, { rating: parsed.data.rating });
revalidatePath("/admin/reviews");
revalidatePath(`/admin/reviews/${id}`);
return { ok: true as const };
}
export async function deleteReviewAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.review.delete({ where: { id } });
await audit("review.delete", id, session?.user?.email ?? null, {});
revalidatePath("/admin/reviews");
return { ok: true as const };
}

View file

@ -0,0 +1,134 @@
import Link from "next/link";
import { listReviewsAdmin } from "@/lib/admin/reviews";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
rating?: string;
withResponse?: string;
}>;
};
function Stars({ rating }: { rating: number }) {
return (
<span className="font-mono text-sm">
<span className="text-amber-500">{"★".repeat(rating)}</span>
<span className="text-zinc-300">{"★".repeat(5 - rating)}</span>
</span>
);
}
export default async function ReviewsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const rating = sp.rating && /^[1-5]$/.test(sp.rating) ? Number(sp.rating) : undefined;
const withResponse = sp.withResponse === "yes" || sp.withResponse === "no" ? (sp.withResponse as "yes" | "no") : undefined;
const filters = {
q: sp.q?.trim() || undefined,
rating,
withResponse,
};
const reviews = await listReviewsAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Avis &amp; modération</h1>
<p className="mt-1 text-sm text-zinc-500">
{reviews.length} résultat{reviews.length > 1 ? "s" : ""}
{reviews.length === 200 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche commentaire, auteur, carbet…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="rating"
defaultValue={sp.rating ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Toutes notes</option>
{[5, 4, 3, 2, 1].map((r) => (
<option key={r} value={String(r)}>{r} étoile{r > 1 ? "s" : ""}</option>
))}
</select>
<select
name="withResponse"
defaultValue={filters.withResponse ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Avec ou sans réponse</option>
<option value="yes">Avec réponse hôte</option>
<option value="no">Sans réponse hôte</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.rating || filters.withResponse) ? (
<Link href="/admin/reviews" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="space-y-3">
{reviews.length === 0 ? (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500">
Aucun avis ne correspond aux filtres.
</div>
) : null}
{reviews.map((r) => (
<article key={r.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<header className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
<div className="flex items-center gap-3">
<Stars rating={r.rating} />
<Link href={`/admin/reviews/${r.id}`} className="text-sm font-semibold text-zinc-900 hover:underline">
{r.author.firstName} {r.author.lastName}
</Link>
<span className="text-[11px] text-zinc-500">{r.author.email}</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<Link href={`/admin/carbets/${r.carbet.id}`} className="hover:text-zinc-900 hover:underline">
{r.carbet.title}
</Link>
· <Link href={`/admin/bookings/${r.booking.id}`} className="font-mono hover:text-zinc-900 hover:underline">
résa {r.booking.id.slice(0, 8)}
</Link>
· {dateFmt.format(r.createdAt)}
</div>
</header>
{r.comment ? (
<p className="whitespace-pre-line text-sm text-zinc-800">{r.comment}</p>
) : (
<p className="text-sm italic text-zinc-400">Pas de commentaire.</p>
)}
{r.hostResponse ? (
<div className="mt-2 rounded border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-700">Réponse hôte</div>
<p className="whitespace-pre-line">{r.hostResponse}</p>
</div>
) : null}
<div className="mt-2 flex items-center justify-end">
<Link
href={`/admin/reviews/${r.id}`}
className="text-xs font-semibold text-zinc-700 hover:text-zinc-900 hover:underline"
>
Modérer
</Link>
</div>
</article>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,171 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
import {
savePlatformSettingsAction,
saveStripeSettingsAction,
saveThemeSettingsAction,
} from "../actions";
type Action = (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
function FormWrapper({
action,
children,
submitLabel = "Enregistrer",
}: {
action: Action;
children: React.ReactNode;
submitLabel?: string;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
{children}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}
export function PlatformForm({
initial,
}: {
initial: { name: string; defaultLang: string; activeLangs: string[]; currency: string; commissionPercent: number };
}) {
return (
<FormWrapper action={savePlatformSettingsAction}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom de la plateforme" required>
<input name="name" defaultValue={initial.name} required maxLength={80} className={inputCls} />
</FormField>
<FormField label="Devise (ISO 4217)" required hint="EUR, USD, BRL…">
<input
name="currency"
defaultValue={initial.currency}
required
pattern="^[A-Z]{3}$"
maxLength={3}
className={inputCls + " uppercase"}
/>
</FormField>
<FormField label="Langue par défaut" required hint="Code ISO 639-1 (fr, en, pt…)">
<input
name="defaultLang"
defaultValue={initial.defaultLang}
required
pattern="^[a-zA-Z]{2}$"
maxLength={2}
className={inputCls + " lowercase"}
/>
</FormField>
<FormField label="Langues actives" required hint="Séparées par virgule (fr, en, pt).">
<input
name="activeLangs"
defaultValue={initial.activeLangs.join(", ")}
required
className={inputCls + " lowercase"}
placeholder="fr, en"
/>
</FormField>
<FormField label="Commission plateforme (%)" hint="Affiché dans les CGV. 0 = pas de commission.">
<input
name="commissionPercent"
type="number"
min={0}
max={100}
step="0.01"
defaultValue={initial.commissionPercent.toString()}
className={inputCls}
/>
</FormField>
</div>
</FormWrapper>
);
}
export function ThemeForm({ initial }: { initial: { active: string } }) {
return (
<FormWrapper action={saveThemeSettingsAction}>
<FormField label="Thème actif" hint="Détermine la skin du site public.">
<select name="active" defaultValue={initial.active} className={selectCls}>
<option value="default">default sobre (admin-like)</option>
<option value="theme-aquarelle">theme-aquarelle carnet naturaliste XIXᵉ</option>
<option value="theme-guyane">theme-guyane palette tropicale</option>
</select>
</FormField>
</FormWrapper>
);
}
export function StripeForm({
initial,
}: {
initial: { currency: string; commissionMode: string; perBookingFeePercent: number };
}) {
return (
<FormWrapper action={saveStripeSettingsAction}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Devise Stripe" required hint="Doit correspondre à la devise plateforme.">
<input
name="currency"
defaultValue={initial.currency}
required
pattern="^[A-Z]{3}$"
maxLength={3}
className={inputCls + " uppercase"}
/>
</FormField>
<FormField label="Modèle économique" required>
<select name="commissionMode" defaultValue={initial.commissionMode} className={selectCls}>
<option value="none">Aucune monétisation (preview)</option>
<option value="owner-subscription">Abonnement loueur (revenu plateforme)</option>
<option value="per-booking">Commission par réservation</option>
</select>
</FormField>
<FormField
label="Commission par réservation (%)"
hint="Utilisé uniquement si modèle = par réservation."
>
<input
name="perBookingFeePercent"
type="number"
min={0}
max={100}
step="0.01"
defaultValue={initial.perBookingFeePercent.toString()}
className={inputCls}
/>
</FormField>
</div>
</FormWrapper>
);
}

View file

@ -0,0 +1,100 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { setSetting } from "@/lib/admin/settings";
import { togglePlugin } from "@/lib/plugins/server";
const platformSchema = z.object({
name: z.string().trim().min(2).max(80),
defaultLang: z.string().trim().length(2),
activeLangs: z.array(z.string().trim().length(2)).min(1).max(10),
currency: z.string().trim().length(3),
commissionPercent: z.coerce.number().min(0).max(100),
});
const themeSchema = z.object({
active: z.enum(["default", "theme-aquarelle", "theme-guyane"]),
});
const stripeSchema = z.object({
currency: z.string().trim().length(3),
commissionMode: z.enum(["none", "owner-subscription", "per-booking"]),
perBookingFeePercent: z.coerce.number().min(0).max(100),
});
async function actor() {
const session = await auth();
return session?.user?.email ?? null;
}
export async function savePlatformSettingsAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const langsRaw = (fd.get("activeLangs") as string | null) ?? "";
const activeLangs = langsRaw
.split(/[,;\s]+/)
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length === 2);
const parsed = platformSchema.safeParse({
name: fd.get("name"),
defaultLang: ((fd.get("defaultLang") as string | null) ?? "").toLowerCase(),
activeLangs,
currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(),
commissionPercent: fd.get("commissionPercent"),
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
if (!parsed.data.activeLangs.includes(parsed.data.defaultLang)) {
return { ok: false as const, error: "La langue par défaut doit faire partie des langues actives." };
}
const who = await actor();
await setSetting("platform", parsed.data, who);
await recordAudit({ scope: "admin.settings", event: "platform.update", actorEmail: who, details: parsed.data });
revalidatePath("/admin/settings");
return { ok: true as const };
}
export async function saveThemeSettingsAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = themeSchema.safeParse({ active: fd.get("active") });
if (!parsed.success) {
return { ok: false as const, error: "Thème invalide." };
}
const who = await actor();
await setSetting("theme", parsed.data, who);
// Le rendu du site public est piloté par l'état des plugins thème.
// On synchronise : un seul plugin actif (ou aucun pour "default").
const wantAquarelle = parsed.data.active === "theme-aquarelle";
const wantGuyane = parsed.data.active === "theme-guyane";
await togglePlugin("theme-aquarelle", wantAquarelle);
await togglePlugin("theme-guyane", wantGuyane);
await recordAudit({ scope: "admin.settings", event: "theme.update", actorEmail: who, details: parsed.data });
revalidatePath("/admin/settings");
revalidatePath("/admin/plugins");
revalidatePath("/");
return { ok: true as const };
}
export async function saveStripeSettingsAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = stripeSchema.safeParse({
currency: ((fd.get("currency") as string | null) ?? "").toUpperCase(),
commissionMode: fd.get("commissionMode"),
perBookingFeePercent: fd.get("perBookingFeePercent"),
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const who = await actor();
await setSetting("stripe", parsed.data, who);
await recordAudit({ scope: "admin.settings", event: "stripe.update", actorEmail: who, details: parsed.data });
revalidatePath("/admin/settings");
return { ok: true as const };
}

View file

@ -0,0 +1,100 @@
import { getAllSettings, readEnvSnapshot } from "@/lib/admin/settings";
import { PlatformForm, StripeForm, ThemeForm } from "./_components/SettingsForms";
export const dynamic = "force-dynamic";
function Badge({ ok, labelOk = "Configuré", labelKo = "Non configuré" }: { ok: boolean; labelOk?: string; labelKo?: string }) {
return (
<span
className={
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ring-1 ring-inset " +
(ok ? "bg-emerald-100 text-emerald-800 ring-emerald-300" : "bg-amber-100 text-amber-800 ring-amber-300")
}
>
{ok ? labelOk : labelKo}
</span>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 py-1.5 last:border-b-0">
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="text-sm text-zinc-900">{value}</dd>
</div>
);
}
export default async function SettingsAdminPage() {
const [settings, env] = await Promise.all([getAllSettings(), Promise.resolve(readEnvSnapshot())]);
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Paramètres</h1>
<p className="mt-1 text-sm text-zinc-500">
Configuration plateforme persistée en base + snapshot des variables d&apos;environnement (lecture seule).
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Plateforme</h2>
<PlatformForm initial={settings.platform} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Thème site public</h2>
<ThemeForm initial={settings.theme} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Monétisation Stripe</h2>
<StripeForm initial={settings.stripe} />
<div className="mt-5 rounded border border-zinc-200 bg-zinc-50 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Variables d&apos;environnement Stripe (lecture seule)
</h3>
<dl className="space-y-1.5">
<Row label="STRIPE_SECRET_KEY" value={<Badge ok={env.stripe.secretKeyConfigured} />} />
<Row label="STRIPE_PUBLISHABLE_KEY" value={<Badge ok={env.stripe.publishableKeyConfigured} />} />
<Row label="STRIPE_WEBHOOK_SECRET" value={<Badge ok={env.stripe.webhookSecretConfigured} />} />
<Row label="STRIPE_OWNER_SUBSCRIPTION_PRICE_ID" value={<Badge ok={env.stripe.ownerPriceIdConfigured} labelKo="Manquant ou placeholder" />} />
</dl>
<p className="mt-2 text-[11px] text-zinc-500">
Les clés et secrets restent dans les variables d&apos;environnement du container. Modifications via le déploiement.
</p>
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Stockage médias (S3 / MinIO)</h2>
<dl className="space-y-1.5">
<Row label="Endpoint" value={<code className="text-xs">{env.s3.endpoint ?? "—"}</code>} />
<Row label="Région" value={<code className="text-xs">{env.s3.region ?? "—"}</code>} />
<Row label="Bucket" value={<code className="text-xs">{env.s3.bucket ?? "—"}</code>} />
<Row
label="URL publique"
value={
env.s3.publicUrl ? (
<a href={env.s3.publicUrl} target="_blank" rel="noreferrer" className="text-xs text-zinc-900 hover:underline">
{env.s3.publicUrl}
</a>
) : "—"
}
/>
<Row label="Path-style URL" value={<Badge ok={env.s3.pathStyle} labelOk="Activé" labelKo="Désactivé" />} />
<Row label="MINIO_ROOT_USER" value={<Badge ok={env.s3.rootUserConfigured} />} />
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Déploiement</h2>
<dl className="space-y-1.5">
<Row label="URL publique" value={<code className="text-xs">{env.app.publicUrl ?? "—"}</code>} />
<Row label="URL auth" value={<code className="text-xs">{env.app.authUrl ?? "—"}</code>} />
<Row label="Version" value={<code className="text-xs">{env.app.deploymentVersion ?? "—"}</code>} />
</dl>
</section>
</div>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { UserRole } from "@/generated/prisma/enums";
import { toggleUserActiveAction, updateUserRoleAction } from "../../actions";
const ROLE_OPTIONS: { value: string; label: string }[] = [
{ value: UserRole.OWNER, label: "Propriétaire" },
{ value: UserRole.CE_MANAGER, label: "CE — Manager" },
{ value: UserRole.CE_MEMBER, label: "CE — Membre" },
{ value: UserRole.TOURIST, label: "Touriste" },
{ value: UserRole.ADMIN, label: "Admin" },
];
export function UserActions({
id,
role,
isActive,
}: {
id: string;
role: string;
isActive: boolean;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState(role);
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
function changeRole(next: string) {
setError(null);
setSelectedRole(next);
startTransition(async () => {
const res = await updateUserRoleAction(id, next);
if (res && res.ok === false) {
setError(res.error);
setSelectedRole(role);
}
router.refresh();
});
}
function toggleActive(next: boolean) {
setError(null);
startTransition(async () => {
const res = await toggleUserActiveAction(id, next);
if (res && res.ok === false) setError(res.error);
setConfirmDeactivate(false);
router.refresh();
});
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<label className="text-[11px] uppercase tracking-wider text-zinc-500">Rôle</label>
<select
value={selectedRole}
disabled={pending}
onChange={(e) => changeRole(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none disabled:opacity-50"
>
{ROLE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-zinc-500">État du compte</span>
{isActive ? (
confirmDeactivate ? (
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
<span className="text-xs text-amber-900">Désactiver ce compte ?</span>
<button
type="button"
onClick={() => toggleActive(false)}
disabled={pending}
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
>
Oui, désactiver
</button>
<button
type="button"
onClick={() => setConfirmDeactivate(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDeactivate(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Désactiver
</button>
)
) : (
<button
type="button"
onClick={() => toggleActive(true)}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Réactiver
</button>
)}
</div>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,133 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getUserForAdmin } from "@/lib/admin/users";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { UserActions } from "./_components/UserActions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
const ROLE_LABEL: Record<string, string> = {
OWNER: "Propriétaire",
CE_MANAGER: "CE — Manager",
CE_MEMBER: "CE — Membre",
TOURIST: "Touriste",
ADMIN: "Admin",
};
export default async function UserDetailPage({ params }: PageProps) {
const { id } = await params;
const user = await getUserForAdmin(id);
if (!user) notFound();
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const dateShortFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2">
<Link href="/admin/users" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les utilisateurs
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{user.firstName} {user.lastName}
<StatusBadge status={user.isActive ? "ACTIVE" : "INACTIVE"} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
{user.email} · {ROLE_LABEL[user.role] ?? user.role} · inscrit le {dateFmt.format(user.createdAt)}
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Actions</h2>
<UserActions id={user.id} role={user.role} isActive={user.isActive} />
</section>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
<dl className="space-y-2 text-sm">
<Row label="Email" value={user.email} />
{user.phone ? <Row label="Téléphone" value={user.phone} /> : null}
<Row label="Rôle" value={ROLE_LABEL[user.role] ?? user.role} />
<Row label="Actif" value={user.isActive ? "Oui" : "Non"} />
{user.organization ? (
<Row
label="Organisation"
value={
<Link href={`/admin/organizations/${user.organization.id}`} className="text-zinc-900 hover:underline">
{user.organization.name}
</Link>
}
/>
) : null}
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Statistiques</h2>
<dl className="space-y-2 text-sm">
<Row label="Carbets" value={String(user._count.carbets)} />
<Row label="Réservations" value={String(user._count.bookings)} />
<Row label="Avis publiés" value={String(user._count.reviews)} />
<Row label="Abonnements" value={String(user._count.subscriptions)} />
</dl>
</section>
</div>
{user.carbets.length > 0 ? (
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Carbets du propriétaire</h2>
<ul className="space-y-1.5">
{user.carbets.map((c) => (
<li key={c.id} className="flex items-center justify-between text-sm">
<Link href={`/admin/carbets/${c.id}`} className="text-zinc-900 hover:underline">
{c.title} <code className="text-[11px] text-zinc-500">/{c.slug}</code>
</Link>
<span className="flex items-center gap-2">
<StatusBadge status={c.status} />
<span className="text-[11px] text-zinc-500">{dateShortFmt.format(c.updatedAt)}</span>
</span>
</li>
))}
</ul>
</section>
) : null}
{user.bookings.length > 0 ? (
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Dernières réservations</h2>
<ul className="space-y-1.5">
{user.bookings.map((b) => (
<li key={b.id} className="flex items-center justify-between gap-3 text-sm">
<Link href={`/admin/bookings/${b.id}`} className="text-zinc-900 hover:underline">
{b.carbet.title}
<span className="ml-2 text-[11px] text-zinc-500">
{dateShortFmt.format(b.startDate)} {dateShortFmt.format(b.endDate)}
</span>
</Link>
<span className="flex items-center gap-2">
<span className="font-mono text-[11px] text-zinc-700">
{Number(b.amount).toFixed(2)} {b.currency}
</span>
<StatusBadge status={b.status} />
<StatusBadge status={b.paymentStatus} />
</span>
</li>
))}
</ul>
</section>
) : null}
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 pb-1.5 last:border-b-0 last:pb-0">
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="text-sm text-zinc-900">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
const ROLE_VALUES = new Set<string>([
UserRole.OWNER,
UserRole.CE_MANAGER,
UserRole.CE_MEMBER,
UserRole.TOURIST,
UserRole.ADMIN,
]);
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
await recordAudit({ scope: "admin.users", event, target, actorEmail: actor, details });
}
export async function updateUserRoleAction(id: string, role: string) {
await requireRole([UserRole.ADMIN]);
if (!ROLE_VALUES.has(role)) {
return { ok: false as const, error: "Rôle invalide" };
}
const session = await auth();
if (role !== UserRole.ADMIN) {
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
const current = await prisma.user.findUnique({ where: { id }, select: { role: true } });
if (current?.role === UserRole.ADMIN && adminCount <= 1) {
return { ok: false as const, error: "Impossible de retirer le dernier admin actif." };
}
}
await prisma.user.update({ where: { id }, data: { role: role as UserRole } });
await audit("user.role.update", id, session?.user?.email ?? null, { role });
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${id}`);
return { ok: true as const };
}
export async function toggleUserActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
if (!active) {
const target = await prisma.user.findUnique({ where: { id }, select: { role: true, isActive: true } });
if (target?.role === UserRole.ADMIN) {
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
if (adminCount <= 1) {
return { ok: false as const, error: "Impossible de désactiver le dernier admin." };
}
}
}
await prisma.user.update({ where: { id }, data: { isActive: active } });
await audit("user.active.update", id, session?.user?.email ?? null, { active });
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${id}`);
return { ok: true as const };
}

View file

@ -0,0 +1,136 @@
import Link from "next/link";
import { UserRole } from "@/generated/prisma/enums";
import { listUsersAdmin } from "@/lib/admin/users";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
role?: string;
active?: string;
}>;
};
const ROLE_VALUES = new Set<string>([
UserRole.OWNER,
UserRole.CE_MANAGER,
UserRole.CE_MEMBER,
UserRole.TOURIST,
UserRole.ADMIN,
]);
const ROLE_LABEL: Record<string, string> = {
OWNER: "Propriétaire",
CE_MANAGER: "CE — Manager",
CE_MEMBER: "CE — Membre",
TOURIST: "Touriste",
ADMIN: "Admin",
};
export default async function UsersAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
role: ROLE_VALUES.has(sp.role ?? "") ? (sp.role as UserRole) : undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
};
const users = await listUsersAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Utilisateurs</h1>
<p className="mt-1 text-sm text-zinc-500">
{users.length} résultat{users.length > 1 ? "s" : ""}
{users.length === 300 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche email, nom, téléphone…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="role"
defaultValue={filters.role ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous rôles</option>
{Object.entries(ROLE_LABEL).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.role || filters.active) ? (
<Link href="/admin/users" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Email</th>
<th className="px-4 py-2 text-left font-semibold">Rôle</th>
<th className="px-4 py-2 text-right font-semibold">Carbets</th>
<th className="px-4 py-2 text-right font-semibold">Résas</th>
<th className="px-4 py-2 text-right font-semibold">Avis</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">Inscrit</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{users.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun utilisateur ne correspond aux filtres.
</td>
</tr>
) : null}
{users.map((u) => (
<tr key={u.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/users/${u.id}`} className="font-medium text-zinc-900 hover:underline">
{u.firstName} {u.lastName}
</Link>
</td>
<td className="px-4 py-2 text-zinc-700">{u.email}</td>
<td className="px-4 py-2 text-zinc-700">{ROLE_LABEL[u.role] ?? u.role}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.carbetsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.bookingsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.reviewsCount}</td>
<td className="px-4 py-2"><StatusBadge status={u.isActive ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{dateFmt.format(u.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
await requireRole([UserRole.ADMIN]);
const { id } = await ctx.params;
const media = await prisma.media.findMany({
where: { carbetId: id },
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Key: true, s3Url: true, sortOrder: true },
});
return NextResponse.json(media);
}

View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
const patchSchema = z.object({
title: z.string().min(1).max(200).optional(),
body: z.string().max(100_000).optional(),
published: z.boolean().optional(),
});
function normalizeLang(v: string | null): string {
if (!v) return "fr";
const l = v.toLowerCase().trim();
return /^[a-z]{2}$/.test(l) ? l : "fr";
}
export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) {
await requireRole([UserRole.ADMIN]);
const { slug } = await ctx.params;
const url = new URL(req.url);
const lang = normalizeLang(url.searchParams.get("lang"));
const session = await auth();
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const existing = await prisma.contentPage.findUnique({
where: { slug_lang: { slug, lang } },
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updated = await prisma.contentPage.update({
where: { slug_lang: { slug, lang } },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
...(parsed.data.published !== undefined ? { published: parsed.data.published } : {}),
lastEditedBy: session?.user?.email ?? session?.user?.id ?? null,
},
});
return NextResponse.json({
slug: updated.slug,
lang: updated.lang,
title: updated.title,
published: updated.published,
updatedAt: updated.updatedAt,
});
}

View file

@ -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);
}

View file

@ -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 });
}

View file

@ -16,6 +16,8 @@ import {
parseIsoDate, parseIsoDate,
} from "@/lib/booking"; } from "@/lib/booking";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -27,6 +29,14 @@ type CreateBookingBody = {
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
);
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
@ -78,6 +88,9 @@ export async function POST(request: Request) {
ownerId: true, ownerId: true,
capacity: true, capacity: true,
status: true, status: true,
nightlyPrice: true,
title: true,
owner: { select: { email: true, firstName: true } },
}, },
}); });
@ -183,6 +196,12 @@ export async function POST(request: Request) {
} }
} }
const nights = Math.max(
1,
Math.round((endDate.getTime() - startDate.getTime()) / 86400000),
);
const computedAmount = Number(carbet.nightlyPrice) * nights;
const booking = await prisma.booking.create({ const booking = await prisma.booking.create({
data: { data: {
carbetId: carbet.id, carbetId: carbet.id,
@ -191,7 +210,7 @@ export async function POST(request: Request) {
endDate, endDate,
guestCount, guestCount,
status: BookingStatus.PENDING, status: BookingStatus.PENDING,
amount: 0, amount: computedAmount.toFixed(2),
currency: "EUR", currency: "EUR",
}, },
select: { select: {
@ -207,5 +226,34 @@ export async function POST(request: Request) {
}, },
}); });
// Best-effort emails (n'échouent pas la réservation si Resend down).
const tenant = await prisma.user.findUnique({
where: { id: session.user.id },
select: { email: true, firstName: true, lastName: true },
});
if (tenant) {
sendBookingRequestToTenant(
tenant.email,
tenant.firstName,
booking.id,
carbet.title,
booking.startDate,
booking.endDate,
computedAmount.toFixed(2),
"EUR",
).catch(() => {});
}
if (carbet.owner?.email && tenant) {
sendBookingRequestToOwner(
carbet.owner.email,
carbet.owner.firstName,
booking.id,
carbet.title,
`${tenant.firstName} ${tenant.lastName}`.trim(),
booking.startDate,
booking.endDate,
).catch(() => {});
}
return NextResponse.json({ booking }, { status: 201 }); return NextResponse.json({ booking }, { status: 201 });
} }

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function authorized(req: Request): boolean {
const secret = (process.env.CRON_TOKEN ?? "").trim();
if (!secret) return false;
const header = req.headers.get("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
return token === secret;
}
export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
if (!authorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { task } = await ctx.params;
const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
if (!fn) {
return NextResponse.json(
{ error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
{ status: 404 },
);
}
try {
const result = await fn();
return NextResponse.json({ ok: true, task, result });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : String(e) },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
const schema = z.object({
carbetId: z.string().min(1),
});
async function requireSelf() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauth");
return session.user.id;
}
export async function GET() {
try {
const userId = await requireSelf();
const rows = await prisma.favorite.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
select: { carbetId: true },
});
return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
} catch {
return NextResponse.json({ ids: [] });
}
}
export async function POST(req: Request) {
try {
const userId = await requireSelf();
const parsed = schema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
await prisma.favorite.upsert({
where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
create: { userId, carbetId: parsed.data.carbetId },
update: {},
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
}
export async function DELETE(req: Request) {
try {
const userId = await requireSelf();
const parsed = schema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
await prisma.favorite
.delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
.catch(() => null);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
}

View file

@ -1,7 +1,101 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Probe = {
name: string;
ok: boolean;
latencyMs: number;
details?: string;
};
async function probeDb(): Promise<Probe> {
const t0 = Date.now();
try {
await prisma.$queryRaw`SELECT 1 AS ok`;
return { name: "database", ok: true, latencyMs: Date.now() - t0 };
} catch (e) {
return {
name: "database",
ok: false,
latencyMs: Date.now() - t0,
details: e instanceof Error ? e.message : String(e),
};
}
}
async function probeS3(): Promise<Probe> {
const t0 = Date.now();
const bucket = process.env.S3_BUCKET;
const endpoint = process.env.S3_ENDPOINT;
if (!bucket || !endpoint) {
return { name: "s3", ok: false, latencyMs: 0, details: "S3_BUCKET ou S3_ENDPOINT manquant" };
}
try {
const client = new S3Client({
endpoint,
region: process.env.S3_REGION ?? "us-east-1",
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "",
secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "",
},
});
await client.send(new HeadBucketCommand({ Bucket: bucket }));
return { name: "s3", ok: true, latencyMs: Date.now() - t0 };
} catch (e) {
return {
name: "s3",
ok: false,
latencyMs: Date.now() - t0,
details: e instanceof Error ? e.message : String(e),
};
}
}
function probeResend(): Probe {
return {
name: "resend",
ok: Boolean(process.env.RESEND_API_KEY?.trim()),
latencyMs: 0,
details: process.env.RESEND_API_KEY ? undefined : "RESEND_API_KEY non configuré (dry-run)",
};
}
function probeStripe(): Probe {
const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
const configured = key.length > 0 && !key.includes("REPLACE_ME");
return {
name: "stripe",
ok: configured,
latencyMs: 0,
details: configured ? undefined : "STRIPE_SECRET_KEY non configuré",
};
}
export async function GET() { export async function GET() {
return NextResponse.json({ status: "ok" }); const t0 = Date.now();
const [db, s3] = await Promise.all([probeDb(), probeS3()]);
const resend = probeResend();
const stripe = probeStripe();
const probes = [db, s3, resend, stripe];
// DB est critique (503 si down). Le reste = non bloquant.
const critical = db.ok;
const status = critical ? 200 : 503;
return NextResponse.json(
{
status: critical ? "ok" : "degraded",
version: process.env.DEPLOYMENT_VERSION ?? "unknown",
uptimeSeconds: Math.round(process.uptime()),
latencyMs: Date.now() - t0,
probes,
},
{ status },
);
} }

View file

@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const userId = session.user.id;
const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
role: true,
avatarUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
organizationId: true,
},
}),
prisma.booking.findMany({
where: { tenantId: userId },
select: {
id: true,
carbetId: true,
startDate: true,
endDate: true,
guestCount: true,
status: true,
paymentStatus: true,
amount: true,
currency: true,
createdAt: true,
},
}),
prisma.review.findMany({
where: { authorId: userId },
select: {
id: true,
bookingId: true,
carbetId: true,
rating: true,
comment: true,
createdAt: true,
},
}),
prisma.carbet.findMany({
where: { ownerId: userId },
select: { id: true, slug: true, title: true, status: true, createdAt: true },
}),
prisma.subscription.findMany({
where: { ownerId: userId },
select: { id: true, carbetId: true, status: true, provider: true, startedAt: true },
}),
]);
await recordAudit({
scope: "public.profile",
event: "data.export",
target: userId,
actorEmail: session.user.email ?? null,
details: {},
});
const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`;
return new NextResponse(
JSON.stringify(
{
exportedAt: new Date().toISOString(),
rgpdNotice:
"Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.",
user,
bookings,
reviews,
carbets,
subscriptions,
},
null,
2,
),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
},
},
);
}

View file

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
export const runtime = "nodejs";
async function requireOwnership(mediaId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Non authentifié");
const m = await prisma.media.findUnique({
where: { id: mediaId },
select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
});
if (!m) throw new Error("Média introuvable");
const isAdmin = session.user.role === UserRole.ADMIN;
if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
return { session, media: m };
}
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
try {
const { session, media } = await requireOwnership(id);
await prisma.media.delete({ where: { id } });
await recordAudit({
scope: "uploads",
event: "media.delete",
target: id,
actorEmail: session.user.email ?? null,
details: { carbetId: media.carbetId },
});
return NextResponse.json({ ok: true });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
return NextResponse.json({ error: msg }, { status });
}
}

View file

@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
export const runtime = "nodejs";
const schema = z.object({
carbetId: z.string().min(1),
orderedIds: z.array(z.string()).min(1).max(50),
});
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
}
const { carbetId, orderedIds } = parsed.data;
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true },
});
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
const isAdmin = session.user.role === UserRole.ADMIN;
if (!isAdmin && carbet.ownerId !== session.user.id) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const existing = await prisma.media.findMany({
where: { carbetId, id: { in: orderedIds } },
select: { id: true },
});
if (existing.length !== orderedIds.length) {
return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
}
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
),
);
await recordAudit({
scope: "uploads",
event: "media.reorder",
target: carbetId,
actorEmail: session.user.email ?? null,
details: { count: orderedIds.length },
});
return NextResponse.json({ ok: true });
}

Some files were not shown because too many files have changed in this diff Show more