Compare commits

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

95 commits

Author SHA1 Message Date
07301ae997 fix(mobile): Sprint R — mobile UX audit + burger menu
All checks were successful
CI / test (push) Successful in 2m22s
2026-06-03 03:54:13 +00:00
Ubuntu
62833ee4e6 fix(mobile): Sprint R — burger menu + cart badge visible mobile
All checks were successful
CI / test (pull_request) Successful in 2m39s
Issues critiques identifiées par audit screenshot iPhone 14 (390×844) :

1. SiteHeader cachait TOUTE la navigation derrière `sm:` →
   utilisateur connecté mobile sans aucun lien accessible (Favoris,
   Mes réservations, Mes locations, Mon compte, Espace hôte/CE/etc.).
   Même Connexion/Créer un compte étaient inaccessibles sur mobile
   anonyme.

2. CartBadge `hidden sm:inline` → panier complètement invisible sur
   mobile même quand des items y sont. Le user perdait la trace de
   ses ajouts.

src/components/MobileMenuButton.tsx (NEW) — client component avec :
- Bouton hamburger 9x9 visible uniquement sm:hidden
- Drawer right side, overlay sombre, scroll body bloqué quand ouvert
- 3 sections : Découvrir (publics), Mon compte (si auth), Espaces pro
  (hôte/prestataire/CE/admin selon role + plugins activés)
- Sign out via signOut() de next-auth/react (côté client — évite
  d'importer SignOutButton qui tirerait @/auth donc pg dans le bundle)
- Lien actif highlighted en emerald
- Ferme automatiquement sur changement de pathname (via useRef pour
  éviter setState-in-effect)

SiteHeader.tsx :
- Tous les liens « auth » deviennent explicitement `hidden sm:inline`
  + Connexion/Créer un compte aussi (étaient toujours visibles avant,
  surchargeaient le mobile)
- SignOutButton wrap `hidden sm:inline` pour ne pas dupliquer
- MobileMenuButton ajouté à la fin de la zone droite

CartBadge.tsx : `inline` au lieu de `hidden sm:inline` → visible
quel que soit le viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 03:53:39 +00:00
5845a6b950 feat(prod): Sprint Q — reminders + cleanup cron
All checks were successful
CI / test (push) Successful in 2m27s
2026-06-03 03:24:31 +00:00
Ubuntu
a6ea488732 feat(prod): Sprint Q — reminders J-1 + cleanup cron endpoints
All checks were successful
CI / test (pull_request) Successful in 2m45s
Endpoints automatisables par cron externe (Hermes, GitHub Actions,
ou crontab système) pour gérer les tâches récurrentes de la
plateforme.

src/lib/cron-auth.ts (NEW) : isAuthorizedCronRequest(req) vérifie
l'en-tête Authorization Bearer ${CRON_TOKEN}. Le token est déjà dans
.env.production.

GET /api/cron/reminders :
- Itère bookings carbet CONFIRMED + rentalBookings CONFIRMED dont
  startDate ∈ [now+22h, now+26h] (fenêtre 4h pour absorber les
  éventuels retards de cron).
- Envoie sendBookingReminder (carbet) ou sendRentalReminder (rental).
- Compte bookingSent/bookingErrors et rentalSent/rentalErrors.
- Audit log scope=cron event=cron.reminders.run avec stats.
- Retourne JSON {ok, window, booking:{candidates,sent,errors},
  rental:{candidates,sent,errors}}.

GET /api/cron/cleanup :
- Purge OrgInviteToken expirés depuis > 30j.
- Booking PENDING + paymentStatus≠SUCCEEDED + createdAt > 7j →
  status=CANCELLED + paymentStatus=FAILED (libère le créneau).
- RentalBooking idem + delete RentalItemAvailability associée
  (libère stock) en transaction.
- Audit log scope=cron event=cron.cleanup.run avec compteurs.

src/lib/email.ts :
- sendBookingReminder(to, firstName, bookingId, title, startDate,
  slug) : email rappel J-1 avec CTA vers /reservations/[id].
- sendRentalReminder(to, firstName, rbId, providerName, startDate,
  contactInfo) : rappel pour récup matériel, affiche contacts
  provider (phone + email).

tests/lib/cron-auth.test.ts (6 cas) :
- Refus si CRON_TOKEN absent, header absent, format incorrect (Basic
  ou Token), token mismatch.
- Accept si match exact, accept avec espaces autour du token (defensive).

Total tests : 89/89 ✓.

Schedule recommandé (à brancher côté Hermes ou crontab) :
- GET /api/cron/reminders : 1× par jour à 9h (Authorization: Bearer
  $CRON_TOKEN)
- GET /api/cron/cleanup : 1× par semaine

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 03:23:58 +00:00
9bdb3666a0 feat(ce): Sprint P — seed démo CE + tests invites
All checks were successful
CI / test (push) Successful in 2m28s
2026-06-03 03:13:44 +00:00
Ubuntu
18d19538d3 feat(ce): Sprint P — seed démo CE + tests invites + cleanup helper
All checks were successful
CI / test (pull_request) Successful in 2m40s
Seed plugin `demo-ce-seed` :
- Nouveau descriptor dans registry (category visual).
- src/lib/plugins/seeds/demo-ce.ts : seedDemoCe() + archiveDemoCe().
  Crée org « Comité ESA Kourou (démo) » approved=true + 2 CE_MANAGERs
  + 3 CE_MEMBERs (password "demo") + 2 carbets co-gérés
  (OrganizationCarbetMembership) + 1 RentalProvider org-scoped +
  4 items (hamac, moustiquaire, kayak, réchaud).
- Idempotent : check existence par slug/email avant create. Upsert
  pour users.
- Disable : soft-archive carbets (status=ARCHIVED), delete
  RentalProvider démo (best-effort si pas de booking), delete users
  démo (cascade memberships) + delete org.
- Branchement hooks onEnable/onDisable dans plugins/hooks.ts.

Permet de visualiser le module CE end-to-end sans signup manuel :
admin active le plugin → l'org démo et son écosystème apparaissent
sur le site (badge « Géré par le CE Comité ESA Kourou (démo) » sur
les fiches carbet, items rental dans le catalogue /materiel).

ce-invites.ts refactor :
- Exporte hashToken (déjà sha256, désormais documenté).
- Extrait isInviteValid(row, now=Date) : helper pur testable. La
  fonction getOrgInviteByToken le réutilise.

tests/lib/ce-invites.test.ts (9 cas) :
- hashToken : déterminisme, format sha256 64-hex, inputs différents,
  pas de fuite du plain.
- isInviteValid : non consommé+non expiré → vrai ; consommé → faux ;
  expiré → faux ; les 2 raisons → faux ; injection now pour tests
  temporels.

Total tests : 83/83 ✓ (74 précédents + 9 nouveaux).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 03:13:14 +00:00
eee052b2a8 feat(rental): Sprint O — reversements prestataires
All checks were successful
CI / test (push) Successful in 2m24s
2026-06-03 02:59:49 +00:00
Ubuntu
5be62f012f feat(rental): Sprint O — reversements prestataires (payouts)
All checks were successful
CI / test (pull_request) Successful in 2m40s
Marketplace encaisse centralisé sur System D → besoin de tracer les
virements mensuels aux prestataires tiers. Migration appliquée prod.

Schema :
- Modèle RentalPayoutMark { id, providerId, periodMonth, amount,
  reference, paidAt, paidByEmail }. Unique (providerId, periodMonth)
  → 1 mark = 1 mois = 1 virement par provider.

Lib src/lib/payouts.ts :
- monthKey(d) → 1er du mois minuit UTC (clé de période).
- listProviderPayouts({monthsBack=6}) → grid provider × mois avec
  bookingsCount + grossAmount (itemsTotal) + commission + netAmount
  (gross-commission) + statut paid via RentalPayoutMark. Exclut
  System D (commission 0%, géré par l'asso). Statut « payé » lu
  depuis les marks. Tri : mois desc puis providerName.
- createPayoutMark (idempotent via findUnique avant insert) +
  deletePayoutMark.

Politique : net dû = itemsTotal - commissionAmount (depositTotal
hors flux, collecté par le provider auprès du client). Politique
documentée dans le commentaire en tête de payouts.ts.

/admin/payouts/page.tsx :
- 3 KPIs (À payer / Déjà payé / Mois affichés).
- Une section par mois (6 derniers), tableau provider × CA brut +
  commission + net dû + statut.
- MarkPaidForm : bouton « Marquer payé » → form inline (amount
  pré-rempli avec net dû, reference optionnelle) → action
  markPayoutPaidAction. Statut payé montre amount + ref + bouton
  « Annuler marquage ».

Server actions :
- markPayoutPaidAction (admin only, idempotent, audit
  admin.payouts/payout.mark + payout.already_marked) → envoie
  sendPayoutSent au contactEmail du provider (best-effort).
- unmarkPayoutPaidAction → delete + audit payout.unmark.

Email sendPayoutSent : notification au provider quand un virement est
marqué payé. Inclut amount + reference + lien dashboard.

Sidebar admin gagne entrée « Reversements » sous Activité.

Tests vitest tests/lib/payouts.test.ts (4 cas) : monthKey
normalisation UTC + idempotence + janvier sans bug, formatMonth fr-FR.
Total : 74/74 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:59:16 +00:00
58fd65a4d0 feat(analytics): Sprint N — dashboards CE + admin
All checks were successful
CI / test (push) Successful in 2m25s
2026-06-03 02:46:29 +00:00
Ubuntu
0dc560385d feat(analytics): Sprint N — dashboards CE + admin
All checks were successful
CI / test (pull_request) Successful in 2m34s
src/lib/analytics.ts (NEW) — 3 queries server-only :
- getMonthlyRevenueSeries({organizationId?, monthsBack=12}) → 12
  buckets « YYYY-MM » avec carbetRevenue + rentalRevenue + total.
  Scope optionnel par org via memberships (Booking) et
  provider.organizationId (RentalBooking).
- getCarbetsOccupancy({organizationId?, monthsBack=3}) → liste triée
  par occupancyPct avec bookedNights/totalNights pour chaque carbet
  PUBLISHED (filtré par memberships si org).
- getAdminGlobalKpis() → users (total + breakdown par rôle),
  carbetsPublished, bookings/rentals 30j, revenue 30j, top 5 carbets
  + top 5 providers par CA 30j.

src/components/analytics/MonthlyRevenueChart.tsx (NEW) — bar chart
SVG simple (pas de lib externe), stack carbet + rental, grid Y, tooltips
via <title>, légende couleurs. Responsive overflow-x-auto.

/espace-ce/analytics/page.tsx (NEW) :
- 3 KPIs (CA 12 mois total / Carbet / Matériel)
- MonthlyRevenueChart scopé par org
- Liste taux d'occupation carbets 3 derniers mois (barres horizontales)
- Lien ajouté depuis le dashboard /espace-ce

/admin/analytics/page.tsx (NEW) :
- 4 KPIs (utilisateurs, carbets publiés, bookings 30j, CA 30j)
- Breakdown users par rôle (barres horizontales + pourcentages)
- Carte « Activité 30j » avec bookings carbet + locations matériel
- MonthlyRevenueChart global
- Top 5 carbets (CA 30j) + Top 5 prestataires rental (CA 30j)
- Sidebar admin gagne entrée « Analytics » sous « Vue d'ensemble »

Pas de nouvelle dépendance npm — graphiques en SVG natif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:46:01 +00:00
73d24b70f7 feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (push) Successful in 2m24s
2026-06-03 02:18:33 +00:00
Ubuntu
c564028ca9 feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (pull_request) Successful in 2m37s
Politique de remboursement v1 :
- > 7 jours du début → FULL (location + caution)
- 1 à 7 jours → PARTIAL_50 (50% location + caution intégrale)
- < 24h ou passé → DEPOSIT_ONLY (caution seulement, pas de remboursement
  sur la location)

src/lib/rental-refund.ts (NEW) : computeRentalRefund({startDate,
itemsTotal, depositTotal}) → { itemsRefund, depositRefund, totalRefund,
policy, policyLabel }. Arrondi au centime, support de Decimal.

POST /api/rentals/[id]/cancel :
- Auth multi-rôle : tenant de la booking, RENTAL_PROVIDER nominal ou
  CE_MANAGER de l'org du provider, ADMIN. Détecte `cancelledBy` pour
  adapter l'email.
- Refuse si status ∉ {PENDING, CONFIRMED} (HANDED_OVER → non
  annulable, contacter Karbé).
- Calcule le refund selon la politique.
- Stripe refund best-effort si paymentStatus=SUCCEEDED + stripeSessionId
  existante + isStripeConfigured + totalRefund > 0. Retrieve session →
  payment_intent → refunds.create. Échec Stripe = audit-logged mais
  le flip status continue (l'asso pourra rembourser manuellement).
- Transaction : update RentalBooking (CANCELLED + paymentStatus
  REFUNDED si SUCCEEDED sinon FAILED) + delete RentalItemAvailability
  (libère stock).
- Audit log rental.cancel avec montants, policy, cancelledBy,
  stripeRefundId, stripeRefundError.
- Email best-effort : sendRentalCancelled à tenant + provider (sauf si
  provider est le canceller).

src/components/CancelRentalButton.tsx : composant client confirm dialog
inline avec textarea motif (max 500 chars). Branché sur :
- /mes-locations : « Annuler ma location » sur résa PENDING/CONFIRMED
- BookingDecision (utilisé par /espace-prestataire/reservations ET
  /espace-ce/materiel/reservations) : remplace l'ancienne mini-confirm
  qui flippait juste le status, désormais via la vraie API refund

sendRentalCancelled email : adapté selon cancelledBy ("Vous avez annulé"
/ "<Provider> a annulé" / "L'équipe Karbé a annulé").

tests/lib/rental-refund.test.ts : 8 cas (FULL @ 10+ et 7j, PARTIAL_50,
DEPOSIT_ONLY < 24h et passé, arrondi centime, zéro caution, policyLabel).
Total projet : 70/70 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:17:58 +00:00
7a12848b5b feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (push) Successful in 2m25s
2026-06-03 01:59:53 +00:00
Ubuntu
3a557b6de5 feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (pull_request) Successful in 2m39s
Email automatique pour les invites CE_MEMBER :
- sendCeInviteEmail(to, orgName, inviteUrl, inviterName?) : template
  best-effort (dry-run sans Resend), bouton CTA + lien direct en plain
  text. Mentionne TTL 14j + warning si pas le destinataire attendu.
- createInviteAction branche l'envoi automatique quand un email est
  renseigné dans le formulaire. Audit log gagne emailedAutomatically.
- InviteForm UI : affiche « lien généré · email envoyé » quand un
  email était fourni. Texte d'aide mis à jour.
- Sans email → comportement inchangé : lien à copier manuellement.

Admin /admin/carbets/[id] gagne section memberships :
- src/lib/admin/carbets.ts : getCarbetForEdit inclut organizations +
  listOrganizationsForLink helper (toutes orgs triées approved desc).
- 2 actions admin : linkCarbetToOrganizationAction (idempotent) +
  unlinkCarbetFromOrganizationAction. Audit scope=admin.carbets,
  events carbet.org.link / carbet.org.unlink.
- CarbetMemberships client component : liste les orgs liées (badge
  pending si org non approuvée) + select des orgs disponibles + boutons
  Lier/Délier. Désactive le select quand toutes les orgs sont déjà
  liées.

Le link admin permet de :
- Lier rétroactivement un carbet existant à un CE (cas où l'orga
  intègre un carbet d'un hôte individuel).
- Délier un carbet quand un CE part ou que le carbet repasse en
  gestion individuelle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 01:59:18 +00:00
2b8d786cf9 feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (push) Successful in 2m26s
2026-06-03 00:03:38 +00:00
Ubuntu
ea0e606735 feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (pull_request) Successful in 2m45s
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
  approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
  si au moins 1 org liée.

Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
  (tokenHash, organizationId, email?, createdByUserId, expiresAt,
  usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
  listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
  markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
  base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
  + form de génération de lien (email optionnel = lock email côté
  signup) + liste des invitations avec statut actif/consommé/expiré +
  bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
  revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
  qui crée User CE_MEMBER + organizationId du token + marquage
  usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
  lock email si fourni, masque les fieldsets type de compte (forcé
  CE_MEMBER).

CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
  le rendu content-pages, conditionnée par plugin ce-management.

Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
  match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
  next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.

Dashboard /espace-ce : lien vers /espace-ce/membres en bas.

Migration prod appliquée.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 00:03:03 +00:00
ab1bbb5484 feat(ce): Sprint J — matériel rental côté CE
All checks were successful
CI / test (push) Successful in 2m19s
2026-06-02 23:48:30 +00:00
Ubuntu
caa3d5214f feat(ce): Sprint J — matériel rental côté CE
All checks were successful
CI / test (pull_request) Successful in 2m41s
src/lib/rental-access.ts CE-aware :
- requireRentalProviderSession accepte CE_MANAGER (en plus de
  RENTAL_PROVIDER et ADMIN).
- getCurrentRentalProvider : CE_MANAGER → findFirst par
  organizationId ; RENTAL_PROVIDER → par managedByUserId.
- getCurrentRentalProviderForCe(organizationId) helper explicite.
- canManageRentalProvider gagne un userOrgId? optionnel : vrai si
  manager nominal OU CE_MANAGER + provider.organizationId === userOrgId.
- Callers existants (5 sites : actions.ts + 4 routes API rental)
  passent désormais session.user.organizationId.

Actions /espace-prestataire/actions.ts role-aware :
- requireOwnedProvider() dérive basePath selon le rôle :
  CE_MANAGER → /espace-ce/materiel ; sinon → /espace-prestataire.
- Tous les redirect/revalidatePath utilisent basePath, donc
  createHostItemAction, updateHostItemAction, deleteHostItemAction,
  addItemBlockAction, removeItemBlockAction, updateBookingStatusAction
  emmènent le user vers son espace contextuel après chaque opération.

/espace-ce/materiel/page.tsx — onboarding :
- Plugin gear-rental disabled → message d'info.
- Pas de provider activé → CTA « Activer la location matériel pour
  <org> » (bouton bloqué si org pending, message bannière).
- Provider existant → dashboard avec KPIs (items actifs, résa pending,
  confirmées à venir, revenu 30j) + 2 ActionCards Items + Réservations.

actions.ts (CE) :
- activateRentalProviderForCeAction → crée RentalProvider(organizationId,
  name="Matériel <org>", managedByUserId=session.user.id, approved=true)
  + audit + redirect /espace-ce/materiel.

Pages CE clonées (réutilisent les composants, actions, helpers
existants — zéro duplication de logique métier) :
- /espace-ce/materiel/items/page.tsx (liste)
- /espace-ce/materiel/items/new/page.tsx (HostItemForm)
- /espace-ce/materiel/items/[itemId]/page.tsx (MediaUploader +
  HostItemForm + ItemBlocksManager + ItemInlineDelete)
- /espace-ce/materiel/reservations/page.tsx (BookingDecision)

Tous importent depuis /espace-prestataire/{actions, items, reservations}
pour rester DRY. Les breadcrumbs et links sont adaptés au contexte CE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:47:57 +00:00
03b740dfff feat(ce): Sprint I — CRUD carbets côté CE
All checks were successful
CI / test (push) Successful in 2m26s
2026-06-02 23:34:48 +00:00
Ubuntu
74ea280f28 feat(ce): Sprint I — CRUD carbets côté CE
All checks were successful
CI / test (pull_request) Successful in 2m36s
Session étendue :
- Ajout session.user.organizationId (typedef + auth callbacks JWT
  & session). Permet à canManageCarbet de check membership sans
  refetch DB.

src/lib/carbet-access.ts :
- MANAGER_ROLES inclut désormais CE_MANAGER → /espace-hote ET /espace-ce
  sont gardés par requireOwnerSession (CE_MANAGER passe, OWNER passe,
  ADMIN passe).
- canManageCarbet(session, carbetOwnerId, linkedOrgIds=[]) :
  - ADMIN → toujours vrai
  - OWNER + session.user.id === carbetOwnerId → vrai
  - CE_MANAGER + session.user.organizationId ∈ linkedOrgIds → vrai
  - sinon faux.
- Callers historiques (qui ne passent pas linkedOrgIds) restent sûrs :
  CE_MANAGER ne peut rien gérer par défaut.

createCarbet étendu : si role=CE_MANAGER + organizationId présent,
crée OrganizationCarbetMembership dans la même transaction. Redirige
ensuite vers /espace-ce/carbets/[id] au lieu de /espace-hote/.

Sweep des callers canManageCarbet (8 sites) : chargent désormais
`Carbet.organizations` + passent linkedOrgIds. Includes :
- updateCarbet, setCarbetStatus, deleteCarbet, reorderMedia, deleteMedia
  dans espace-hote/carbets/actions.ts
- espace-hote/carbets/[carbetId]/page.tsx
- API POST /api/carbets/[carbetId]/media

Pages /espace-ce/carbets/* :
- page.tsx : liste les carbets co-gérés via OrganizationCarbetMembership,
  forms Publier/Dépublier/Supprimer pointent vers les actions
  existantes de /espace-hote (réutilisation totale)
- nouveau/page.tsx : requireApprovedOrg (redirect dashboard si pending),
  CarbetForm + createCarbet (même action que /espace-hote — détecte
  CE_MANAGER et crée membership)
- [carbetId]/page.tsx : vérif que le carbet est lié à l'org du user
  + MediaUploader + CarbetForm (updateCarbet partagé)

Dashboard /espace-ce/page.tsx : ActionCard « Mes carbets » devient
active (le lien marche même en pending — l'org peut préparer des
brouillons, c'est juste la publication qui est bloquée).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:34:17 +00:00
3d77632ba0 feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (push) Successful in 2m21s
2026-06-02 23:13:15 +00:00
Ubuntu
63a29d9ade feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (pull_request) Successful in 2m33s
src/lib/ce-access.ts (NEW) :
- requireCeManagerSession (redirect connexion ou / si rôle insuffisant)
- getCurrentCeOrganization (CE_MANAGER → son org via organizationId,
  ADMIN → org ciblée par paramètre ou null)
- canManageCarbetForCe (owner direct OU membre d'une org liée)
- requireApprovedOrg (redirect /espace-ce?pending=1 si non validée)

Emails best-effort :
- sendNewCeRequest → admin (contact@karbe) avec lien filtré
  /admin/organizations?status=pending
- sendCeApproved → CE_MANAGERs actifs de l'org après validation
- Branchement dans approveOrganizationAction : envoie le mail à tous
  les CE_MANAGERs actifs de l'org en best-effort.

Signup CE public :
- SignupForm 4e tuile « Comité d'Entreprise » avec champ orgName.
  Layout grid 4 colonnes sur lg, 2 sur sm.
- /api/signup étendu :
  - zod accepte CE_MANAGER + orgName
  - transaction $tx atomique : Organization (approved=false, slug
    auto-unique via slugify + suffix) + User (role=CE_MANAGER,
    organizationId lié)
  - sendNewCeRequest best-effort
  - réponse étendue avec organizationId
- Pattern slug : retry avec suffix -2, -3… jusqu'à libre

Dashboard /espace-ce :
- layout.tsx : requirePluginOr404("ce-management") +
  requireCeManagerSession
- page.tsx : 4 KPIs (carbets co-gérés, items rental, bookings 30j,
  revenu 30j), bannière « En attente de validation » si pending,
  2 ActionCards (Mes carbets, Matériel rental) marquées « Bientôt »
  jusqu'aux sprints I et J
- ce-dashboard.ts : getCeOrgKpis (agrège bookings carbets via
  membership + rentalBookings via provider.organizationId) +
  listCeCarbets pour Sprint I

SiteHeader : lien « Espace CE » conditionné par role + plugin
(mirror du lien Espace prestataire).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:12:46 +00:00
8609c3c98b feat(ce): Sprint G — data model + admin validation
All checks were successful
CI / test (push) Successful in 2m19s
2026-06-02 22:56:28 +00:00
Ubuntu
946dd8d5d2 feat(ce): Sprint G — data model + admin validation
All checks were successful
CI / test (pull_request) Successful in 2m38s
Schema:
- Organization gagne le workflow d'approbation (approved + approvedAt +
  approvedBy + contactEmail). Backfill : toutes les orgs existantes
  (CMCK) → approved=true via migration.
- Nouveau OrganizationCarbetMembership (manyToMany Org↔Carbet) pour la
  co-gestion CE : un Carbet a un ownerId (créateur initial) + 0..n
  memberships ; chaque CE_MANAGER d'une org liée peut gérer le carbet
  en plus de l'owner. Pour un hôte individuel = pas de membership.
- RentalProvider.organizationId (nullable, SetNull on delete) : un CE
  peut posséder son provider ; les CE_MANAGERs membres de l'org y ont
  accès en plus du manager nominal.

Plugin ce-management ajouté au registry (catégorie business, off par
défaut). Quand off : signup CE caché + dashboard /espace-ce 404.

Admin organizations :
- Tab statut (Toutes / À valider [count] / Validées) avec compteur des
  organisations pending dans l'en-tête.
- Badge statut sur la liste et la page détail.
- Bouton « Valider l'organisation » sur le détail (action
  approveOrganizationAction → flip approved=true + approvedAt + audit
  log organization.approve). Idempotent : un re-appel sur une org déjà
  validée ne re-loggue pas.
- Détail montre les compteurs carbetMemberships + rentalProviders.

Migration appliquée à la DB prod (CMCK backfill validé).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 22:55:54 +00:00
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
240 changed files with 23045 additions and 343 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"

2460
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,18 +7,30 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@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/client": "^7.8.0",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3",
"leaflet": "^1.9.4",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "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"
},
"devDependencies": {
@ -26,11 +38,13 @@
"@types/node": "^20.19.41",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^3.2.4",
"dotenv": "^17.4.2",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}

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

@ -0,0 +1,54 @@
-- Sprint G : CE management.
-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy)
-- + un contactEmail dédié pour les notifications admin.
-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les
-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être
-- co-publié par plusieurs orgs (cas rare mais autorisé).
-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider.
ALTER TABLE "Organization"
ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN "approvedAt" TIMESTAMP(3),
ADD COLUMN "approvedBy" TEXT;
CREATE INDEX "Organization_approved_idx" ON "Organization"("approved");
-- Backfill : toutes les orgs existantes sont considérées validées.
-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront
-- en approved=false par défaut.)
UPDATE "Organization"
SET "approved" = TRUE,
"approvedAt" = NOW()
WHERE "approved" = FALSE;
CREATE TABLE "OrganizationCarbetMembership" (
"organizationId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"addedByUserId" TEXT,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId")
);
CREATE INDEX "OrganizationCarbetMembership_carbetId_idx"
ON "OrganizationCarbetMembership"("carbetId");
ALTER TABLE "OrganizationCarbetMembership"
ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationCarbetMembership"
ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey"
FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RentalProvider"
ADD COLUMN "organizationId" TEXT;
CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId");
ALTER TABLE "RentalProvider"
ADD CONSTRAINT "RentalProvider_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,22 @@
-- Sprint K : tokens d'invitation CE_MEMBER.
-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit
-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation.
CREATE TABLE "OrgInviteToken" (
"tokenHash" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT,
"createdByUserId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash")
);
CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId");
CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt");
ALTER TABLE "OrgInviteToken"
ADD CONSTRAINT "OrgInviteToken_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,28 @@
-- Sprint O : reversements prestataires.
-- RentalPayoutMark trace les virements bancaires manuels effectués par System D
-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue
-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher
-- les marquages en doublon.
CREATE TABLE "RentalPayoutMark" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"periodMonth" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(10, 2) NOT NULL,
"reference" TEXT,
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"paidByEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key"
ON "RentalPayoutMark"("providerId", "periodMonth");
CREATE INDEX "RentalPayoutMark_periodMonth_idx"
ON "RentalPayoutMark"("periodMonth");
ALTER TABLE "RentalPayoutMark"
ADD CONSTRAINT "RentalPayoutMark_providerId_fkey"
FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -13,6 +13,7 @@ enum UserRole {
CE_MEMBER
TOURIST
ADMIN
RENTAL_PROVIDER
}
enum CarbetStatus {
@ -71,16 +72,59 @@ enum TransportMode {
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
slug String @unique
description String?
contactEmail String?
approved Boolean @default(false)
approvedAt DateTime?
approvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members User[]
members User[]
carbetMemberships OrganizationCarbetMembership[]
rentalProviders RentalProvider[]
invites OrgInviteToken[]
@@index([name])
@@index([approved])
}
/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
model OrgInviteToken {
tokenHash String @id
organizationId String
email String?
createdByUserId String?
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([expiresAt])
}
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
model OrganizationCarbetMembership {
organizationId String
carbetId String
addedByUserId String?
addedAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@id([organizationId, carbetId])
@@index([carbetId])
}
model User {
@ -97,11 +141,13 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
rentalProviders RentalProvider[]
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
@@index([organizationId])
@@index([role])
@ -124,6 +170,13 @@ model Carbet {
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
roadAccessNote String?
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?
@ -147,6 +200,7 @@ model Carbet {
bookings Booking[]
reviews Review[]
subscriptions Subscription[]
organizations OrganizationCarbetMembership[]
@@index([ownerId])
@@index([status])
@ -242,7 +296,8 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
review Review?
review Review?
rentalBookings RentalBooking[]
@@index([carbetId])
@@index([tenantId])
@ -326,3 +381,239 @@ model ContentPage {
@@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?
/// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
/// membre de l'org peut gérer items et réservations en plus du manager nominal.
organizationId 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)
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
items RentalItem[]
rentalBookings RentalBooking[]
payoutMarks RentalPayoutMark[]
@@index([active, approved])
@@index([managedByUserId])
@@index([organizationId])
}
/// Trace les reversements bancaires manuels (System D paie le provider hors plateforme).
/// La période est représentée par le mois (1er du mois minuit UTC) ; unique par
/// (provider, période) pour empêcher de marquer 2 fois le même mois.
model RentalPayoutMark {
id String @id @default(cuid())
providerId String
/// 1er du mois minuit UTC — sert de clé de période.
periodMonth DateTime
/// Montant effectivement viré au provider, en euros.
amount Decimal @db.Decimal(10, 2)
/// Référence de virement (optionnelle, à coller depuis la banque).
reference String?
paidAt DateTime @default(now())
paidByEmail String?
createdAt DateTime @default(now())
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
@@unique([providerId, periodMonth])
@@index([periodMonth])
}
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)"

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,169 @@
import Link from "next/link";
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics";
export const dynamic = "force-dynamic";
export const metadata = { title: "Analytics globaux — Karbé admin" };
const ROLE_LABEL: Record<string, string> = {
ADMIN: "Admin",
OWNER: "Hôte",
RENTAL_PROVIDER: "Loueur matériel",
CE_MANAGER: "CE Manager",
CE_MEMBER: "CE Membre",
TOURIST: "Voyageur",
};
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export default async function AdminAnalyticsPage() {
const [kpis, series] = await Promise.all([
getAdminGlobalKpis(),
getMonthlyRevenueSeries({ monthsBack: 12 }),
]);
return (
<div className="mx-auto max-w-6xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Analytics globaux</h1>
<p className="mt-1 text-sm text-zinc-500">
Vue d&apos;ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers.
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Utilisateurs" value={kpis.usersTotal} />
<KpiCard label="Carbets publiés" value={kpis.carbetsPublished} />
<KpiCard label="Bookings 30j" value={kpis.bookings30d} />
<KpiCard label="CA 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div 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">
Utilisateurs par rôle
</h2>
{kpis.usersTotal === 0 ? (
<p className="text-sm text-zinc-500">Aucun utilisateur.</p>
) : (
<ul className="space-y-1.5 text-sm">
{Object.entries(kpis.usersByRole)
.sort((a, b) => b[1] - a[1])
.map(([role, count]) => {
const pct = Math.round((count / kpis.usersTotal) * 100);
return (
<li key={role}>
<div className="flex items-baseline justify-between">
<span className="text-zinc-700">{ROLE_LABEL[role] ?? role}</span>
<span className="font-mono text-xs text-zinc-700">
{count} ({pct}%)
</span>
</div>
<div className="mt-0.5 h-1.5 overflow-hidden rounded-full bg-zinc-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</div>
<div 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">
Activité 30 derniers jours
</h2>
<ul className="space-y-2 text-sm">
<li className="flex items-baseline justify-between">
<span className="text-zinc-700">Bookings carbet</span>
<span className="font-mono font-semibold text-zinc-900">{kpis.bookings30d}</span>
</li>
<li className="flex items-baseline justify-between">
<span className="text-zinc-700">Locations matériel</span>
<span className="font-mono font-semibold text-zinc-900">{kpis.rentals30d}</span>
</li>
<li className="flex items-baseline justify-between border-t border-zinc-100 pt-2">
<span className="font-semibold text-zinc-900">Total CA 30j</span>
<span className="font-mono font-semibold text-emerald-700">
{fmtEur(kpis.revenue30d)}
</span>
</li>
</ul>
</div>
</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">
Chiffre d&apos;affaires mensuel
</h2>
<MonthlyRevenueChart data={series} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div 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">
Top carbets (30j)
</h2>
{kpis.topCarbets.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune réservation sur les 30 derniers jours.</p>
) : (
<ul className="space-y-2 text-sm">
{kpis.topCarbets.map((c, i) => (
<li key={c.carbetId} className="flex items-baseline justify-between">
<span>
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
<Link href={`/admin/carbets/${c.carbetId}`} className="text-zinc-900 hover:underline">
{c.title}
</Link>
</span>
<span className="font-mono text-zinc-700">{fmtEur(c.revenue)}</span>
</li>
))}
</ul>
)}
</div>
<div 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">
Top prestataires rental (30j)
</h2>
{kpis.topProviders.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune location sur les 30 derniers jours.</p>
) : (
<ul className="space-y-2 text-sm">
{kpis.topProviders.map((p, i) => (
<li key={p.providerId} className="flex items-baseline justify-between">
<span>
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
<Link
href={`/admin/rental-providers/${p.providerId}`}
className="text-zinc-900 hover:underline"
>
{p.name}
</Link>
</span>
<span className="font-mono text-zinc-700">{fmtEur(p.revenue)}</span>
</li>
))}
</ul>
)}
</div>
</section>
</div>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}

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

@ -5,9 +5,11 @@ 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: unknown) {
console.log(JSON.stringify({ scope: "admin.bookings", event, target, actor, details, at: new Date().toISOString() }));
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>([
@ -30,11 +32,32 @@ export async function updateBookingStatusAction(id: string, status: string) {
return { ok: false as const, error: "Statut invalide" };
}
const session = await auth();
await prisma.booking.update({
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 };
@ -59,14 +82,26 @@ export async function updateBookingPaymentAction(id: string, paymentStatus: stri
export async function refundBookingAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.booking.update({
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,125 @@
"use client";
import { useState, useTransition } from "react";
type Org = { id: string; name: string; slug: string; approved: boolean };
type LinkedOrg = Org & { addedAt: Date };
type Props = {
carbetId: string;
linked: LinkedOrg[];
available: Org[];
linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>;
unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>;
};
export function CarbetMemberships({
carbetId,
linked,
available,
linkAction,
unlinkAction,
}: Props) {
const [pending, startTransition] = useTransition();
const [selectedOrgId, setSelectedOrgId] = useState("");
const [error, setError] = useState<string | null>(null);
// Filtre les orgs non encore liées
const linkedIds = new Set(linked.map((l) => l.id));
const options = available.filter((o) => !linkedIds.has(o.id));
function link() {
if (!selectedOrgId) return;
setError(null);
startTransition(async () => {
const res = await linkAction(carbetId, selectedOrgId);
if (!res.ok) setError(res.error || "Échec de la liaison");
else setSelectedOrgId("");
});
}
function unlink(orgId: string) {
setError(null);
startTransition(async () => {
const res = await unlinkAction(carbetId, orgId);
if (!res.ok) setError(res.error || "Échec");
});
}
return (
<div className="space-y-3">
{linked.length === 0 ? (
<p className="text-sm text-zinc-500">
Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-md border border-zinc-200 bg-white">
{linked.map((o) => (
<li
key={o.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div>
<span className="font-medium text-zinc-900">{o.name}</span>
<span className="ml-2 text-[11px] text-zinc-500">/{o.slug}</span>
{!o.approved ? (
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
Pending
</span>
) : null}
</div>
<button
type="button"
disabled={pending}
onClick={() => unlink(o.id)}
className="rounded border border-rose-200 bg-white px-2 py-1 text-[11px] text-rose-700 hover:bg-rose-50 disabled:opacity-60"
>
Délier
</button>
</li>
))}
</ul>
)}
{options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(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"
>
<option value=""> Choisir une organisation à lier </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.name} {o.approved ? "" : "(pending)"}
</option>
))}
</select>
<button
type="button"
disabled={pending || !selectedOrgId}
onClick={link}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "…" : "Lier"}
</button>
</div>
) : (
<p className="text-[11px] text-zinc-500">
Toutes les organisations existantes sont déjà liées à ce carbet.
</p>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{error}
</div>
) : null}
<p className="text-[11px] text-zinc-500">
Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du
propriétaire nominal.
</p>
</div>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import Image from "next/image";
import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
@ -125,7 +124,7 @@ export function MediaManager({ carbetId, media: initial }: { carbetId: string; m
</select>
</FormField>
</div>
<input type="hidden" name="s3Key" value={`external/${Date.now()}`} />
{/* 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

View file

@ -1,15 +1,23 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { MediaUploader } from "@/components/MediaUploader";
import { StatusBadge } from "@/components/admin/StatusBadge";
import {
getCarbetForEdit,
listOrganizationsForLink,
listOwners,
listPirogueProviders,
} from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaManager } from "./_components/MediaManager";
import {
linkCarbetToOrganizationAction,
unlinkCarbetFromOrganizationAction,
updateCarbetAction,
} from "../actions";
import { CarbetMemberships } from "./_components/CarbetMemberships";
import { StatusActions } from "./_components/StatusActions";
import { updateCarbetAction } from "../actions";
export const dynamic = "force-dynamic";
@ -17,10 +25,11 @@ type PageProps = { params: Promise<{ id: string }> };
export default async function EditCarbetPage({ params }: PageProps) {
const { id } = await params;
const [carbet, owners, providers] = await Promise.all([
const [carbet, owners, providers, organizations] = await Promise.all([
getCarbetForEdit(id),
listOwners(),
listPirogueProviders(),
listOrganizationsForLink(),
]);
if (!carbet) notFound();
@ -28,6 +37,14 @@ export default async function EditCarbetPage({ params }: PageProps) {
"use server";
return await updateCarbetAction(id, fd);
};
const linkThis = async (carbetId: string, orgId: string) => {
"use server";
return await linkCarbetToOrganizationAction(carbetId, orgId);
};
const unlinkThis = async (carbetId: string, orgId: string) => {
"use server";
return await unlinkCarbetFromOrganizationAction(carbetId, orgId);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
@ -61,16 +78,40 @@ export default async function EditCarbetPage({ params }: PageProps) {
</div>
</header>
<MediaManager
carbetId={carbet.id}
media={carbet.media.map((m) => ({
id: m.id,
type: m.type,
s3Key: m.s3Key,
s3Url: m.s3Url,
sortOrder: m.sortOrder,
}))}
/>
<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">
Organisations co-gestionnaires (CE)
</h2>
<CarbetMemberships
carbetId={carbet.id}
linked={carbet.organizations.map((m) => ({
id: m.organization.id,
name: m.organization.name,
slug: m.organization.slug,
approved: m.organization.approved,
addedAt: m.addedAt,
}))}
available={organizations}
linkAction={linkThis}
unlinkAction={unlinkThis}
/>
</section>
<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}
@ -87,7 +128,12 @@ export default async function EditCarbetPage({ params }: PageProps) {
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,

View file

@ -18,7 +18,12 @@ export type CarbetFormInitial = {
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;
@ -188,9 +193,66 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
</div>
</section>
{/* Séjour */}
{/* Critères opérationnels */}
<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</h2>
<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
@ -203,6 +265,17 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
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"

View file

@ -5,11 +5,14 @@ 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";
@ -26,7 +29,18 @@ const baseCarbetSchema = z.object({
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(),
@ -51,9 +65,11 @@ function parseFromFormData(fd: FormData) {
if (typeof v === "string") obj[k] = v;
}
// Normalise les champs optionnels nullables
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach(
["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;
}
@ -197,23 +213,53 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire
return { ok: true as const };
}
/**
* Audit léger : log dans la console (Sprint 5 ajoutera une table AuditLog).
* Pour l'instant on a au moins une trace dans les logs du container.
*/
export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
// findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas.
const existing = await prisma.organizationCarbetMembership.findUnique({
where: { organizationId_carbetId: { organizationId, carbetId } },
select: { organizationId: true },
});
if (existing) {
return { ok: true as const, alreadyLinked: true };
}
await prisma.organizationCarbetMembership.create({
data: {
organizationId,
carbetId,
addedByUserId: session?.user?.id ?? null,
},
});
await audit("carbet.org.link", carbetId, actorEmail, { organizationId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const, alreadyLinked: false };
}
export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
await prisma.organizationCarbetMembership
.delete({ where: { organizationId_carbetId: { organizationId, carbetId } } })
.catch(() => {});
await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
async function audit(
action: string,
event: string,
entityId: string,
actor: string | null,
payload: Record<string, unknown>,
) {
console.log(
JSON.stringify({
audit: action,
actor,
entityId,
payload,
at: new Date().toISOString(),
}),
);
await recordAudit({
scope: "admin.carbets",
event,
target: entityId,
actorEmail: actor,
details: payload,
});
}

View file

@ -99,6 +99,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
<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>
@ -109,7 +110,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
<tbody className="divide-y divide-zinc-100">
{carbets.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun carbet ne correspond aux filtres.
</td>
</tr>
@ -129,6 +130,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
{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>

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
type Page = {
slug: string;
lang: string;
title: string;
body: string;
category: string;
@ -25,11 +26,14 @@ export default function EditorForm({ page }: { page: Page }) {
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, published }),
});
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}`);

View file

@ -2,46 +2,90 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { getContentPage } from "@/lib/content-pages";
import { prisma } from "@/lib/prisma";
import EditorForm from "./_components/EditorForm";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ slug: string }> };
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ lang?: string }>;
};
export default async function EditContentPage({ params }: PageProps) {
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;
// Pas getContentPage : il filtre published=true. Ici on veut tout voir.
// Admin édite la version FR par défaut. (Édition EN = future feature.)
const row = await prisma.contentPage.findUnique({
where: { slug_lang: { slug, lang: "fr" } },
});
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();
// Re-construction du type minimal attendu par le formulaire.
const page = {
slug: row.slug,
lang: row.lang,
title: row.title,
body: row.body,
category: row.category,
published: row.published,
updatedAt: row.updatedAt,
};
// Mute eslint sur le _ = getContentPage (gardé importé pour la cohérence future).
void getContentPage;
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Link
href="/admin/content-pages"
className="text-sm text-gray-600 hover:text-gray-900"
>
Toutes les pages
</Link>
<h1 className="mt-3 text-2xl font-semibold">Éditer · {page.title}</h1>
<p className="mt-1 text-sm text-gray-600">
URL publique : <code>/{page.slug}</code>
</p>
<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>

View file

@ -10,50 +10,146 @@ const CATEGORY_LABEL: Record<string, string> = {
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 pages = await listContentPages();
const rows = await listContentPages();
const byCategory = pages.reduce<Record<string, typeof pages>>((acc, p) => {
// 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;
}, {});
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold">Pages éditoriales</h1>
<p className="mt-2 text-sm text-gray-600">
Pages markdown affichées dans le site public. La catégorie « Général »
est gérée par le plugin <code>content-pages</code>, la catégorie « Légales »
par <code>legal-pages</code>. Désactiver le plugin dépublie ses pages
sans les supprimer.
</p>
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
<div className="mt-6 space-y-8">
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-sm font-semibold uppercase tracking-wide text-gray-500">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">
{CATEGORY_LABEL[cat] ?? cat}
</h2>
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{list.map((p) => (
<li key={p.slug} className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<div className="font-medium">{p.title}</div>
<div className="text-xs text-gray-500">
<code>/{p.slug}</code> · {p.published ? "publié" : "dépublié"} ·
mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")}
</div>
</div>
<Link
href={`/admin/content-pages/${encodeURIComponent(p.slug)}`}
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold text-white hover:bg-gray-800"
>
Éditer
</Link>
</li>
))}
</ul>
<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>

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

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,36 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
};
export function ApproveOrgButton({ action }: Props) {
const [pending, startTransition] = useTransition();
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);
}
});
}
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={run}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
>
{pending ? "Validation…" : "Valider l'organisation"}
</button>
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
</div>
);
}

View file

@ -3,7 +3,8 @@ 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 { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions";
import { ApproveOrgButton } from "./_components/ApproveOrgButton";
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
export const dynamic = "force-dynamic";
@ -31,6 +32,10 @@ export default async function EditOrgPage({ params }: PageProps) {
"use server";
return await deleteOrganizationAction(id);
};
const approveThis = async () => {
"use server";
return await approveOrganizationAction(id);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) {
<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>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{org.name}
{org.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">
Validée
</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">
À valider
</span>
)}
</h1>
<p className="mt-1 text-sm text-zinc-500">
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "}
{org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré
{org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental
</p>
{org.contactEmail ? (
<p className="text-xs text-zinc-500">
Contact : <a href={`mailto:${org.contactEmail}`} className="underline">{org.contactEmail}</a>
</p>
) : null}
</div>
<div className="flex items-center gap-2">
{!org.approved ? <ApproveOrgButton action={approveThis} /> : null}
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
</div>
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">

View file

@ -5,11 +5,14 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations";
import { requireRole } from "@/lib/authorization";
import { sendCeApproved } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
async function audit(event: string, target: string, actor: string | null, details: unknown) {
console.log(JSON.stringify({ scope: "admin.organizations", event, target, actor, details, at: new Date().toISOString() }));
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])?$/;
@ -74,6 +77,38 @@ export async function updateOrganizationAction(id: string, fd: FormData) {
return { ok: true as const };
}
export async function approveOrganizationAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const res = await approveOrganizationLib(id, actor ?? "admin");
if (!res.ok) return res;
if (!res.alreadyApproved) {
await audit("organization.approve", id, actor, {});
// Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué.
try {
const data = await prisma.organization.findUnique({
where: { id },
select: {
name: true,
members: {
where: { role: UserRole.CE_MANAGER, isActive: true },
select: { email: true, firstName: true },
},
},
});
for (const m of data?.members ?? []) {
await sendCeApproved(m.email, m.firstName, data?.name ?? "");
}
} catch (e) {
console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e);
}
}
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();

View file

@ -1,16 +1,27 @@
import Link from "next/link";
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ q?: string }>;
searchParams: Promise<{ q?: string; status?: string }>;
};
const STATUS_VALUES = ["all", "pending", "approved"] as const;
type StatusFilter = (typeof STATUS_VALUES)[number];
function isStatusFilter(s: string | undefined): s is StatusFilter {
return STATUS_VALUES.includes(s as StatusFilter);
}
export default async function OrgsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = { q: sp.q?.trim() || undefined };
const orgs = await listOrganizationsAdmin(filters);
const approved = isStatusFilter(sp.status) ? sp.status : "all";
const filters = { q: sp.q?.trim() || undefined, approved };
const [orgs, pendingCount] = await Promise.all([
listOrganizationsAdmin(filters),
countPendingOrganizations(),
]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
@ -30,7 +41,35 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
</Link>
</header>
<nav className="mb-3 flex flex-wrap gap-2 text-sm">
{(
[
{ key: "all", label: "Toutes" },
{ key: "pending", label: pendingCount > 0 ? `À valider (${pendingCount})` : "À valider" },
{ key: "approved", label: "Validées" },
] as { key: StatusFilter; label: string }[]
).map((t) => {
const href = `/admin/organizations?status=${t.key}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ""}`;
const active = approved === t.key;
return (
<Link
key={t.key}
href={href}
className={
"rounded-md px-3 py-1 font-medium " +
(active ? "bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-700 hover:bg-zinc-200")
}
>
{t.label}
</Link>
);
})}
</nav>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
{approved !== "all" ? (
<input type="hidden" name="status" value={approved} />
) : null}
<input
type="text"
name="q"
@ -53,6 +92,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
<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">Statut</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>
@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
<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">
<td colSpan={5} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune organisation.
</td>
</tr>
@ -76,6 +116,17 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
<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">
{o.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">
Validée
</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">
À valider
</span>
)}
</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>

View file

@ -1,3 +1,4 @@
import Link from "next/link";
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
import { KPICard } from "@/components/admin/KPICard";
@ -66,34 +67,34 @@ export default async function AdminDashboard() {
</h2>
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
<li>
<a href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<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
</a>
</Link>
</li>
<li>
<a href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<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
</a>
</Link>
</li>
<li>
<a href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<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
</a>
</Link>
</li>
<li>
<a href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<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
</a>
</Link>
</li>
<li>
<a href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<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
</a>
</Link>
</li>
<li>
<a href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<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
</a>
</Link>
</li>
</ul>
</section>

View file

@ -0,0 +1,126 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { ProviderPayout } from "@/lib/payouts";
type Props = {
payout: ProviderPayout;
markAction: (
providerId: string,
periodMonthISO: string,
fd: FormData,
) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>;
unmarkAction: (providerId: string, periodMonthISO: string) => Promise<void>;
};
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [opened, setOpened] = useState(false);
const [error, setError] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd);
if (!res.ok) {
setError(res.error);
return;
}
setOpened(false);
router.refresh();
});
}
function onUnmark() {
startTransition(async () => {
await unmarkAction(payout.providerId, payout.periodMonth.toISOString());
router.refresh();
});
}
if (payout.paid) {
return (
<div className="flex flex-col items-end gap-1 text-right">
<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">
Payé {fmtEur(payout.paid.amount)}
</span>
{payout.paid.reference ? (
<span className="font-mono text-[10px] text-zinc-500">Ref : {payout.paid.reference}</span>
) : null}
<button
type="button"
onClick={onUnmark}
disabled={pending}
className="text-[10px] text-zinc-500 hover:text-rose-700"
>
Annuler marquage
</button>
</div>
);
}
if (payout.netAmount <= 0) {
return <span className="text-[11px] text-zinc-400"></span>;
}
if (!opened) {
return (
<button
type="button"
onClick={() => setOpened(true)}
className="rounded-md bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700"
>
Marquer payé
</button>
);
}
return (
<form action={onSubmit} className="flex flex-col items-end gap-1 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
<input
type="number"
name="amount"
step="0.01"
min={0}
defaultValue={payout.netAmount.toFixed(2)}
className="w-24 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
required
/>
<input
type="text"
name="reference"
placeholder="Réf. virement"
maxLength={100}
className="w-32 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
/>
{error ? <span className="text-[10px] text-rose-700">{error}</span> : null}
<div className="flex gap-1">
<button
type="button"
onClick={() => {
setOpened(false);
setError(null);
}}
disabled={pending}
className="text-[10px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
<button
type="submit"
disabled={pending}
className="rounded bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-emerald-700"
>
{pending ? "…" : "Confirmer"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { requireRole } from "@/lib/authorization";
import {
createPayoutMark,
deletePayoutMark,
} from "@/lib/payouts";
import { prisma } from "@/lib/prisma";
import { sendPayoutSent } from "@/lib/email";
export async function markPayoutPaidAction(
providerId: string,
periodMonthISO: string,
fd: FormData,
): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const amount = Number(fd.get("amount") ?? 0);
const reference = ((fd.get("reference") as string | null) ?? "").trim() || null;
if (!Number.isFinite(amount) || amount < 0) {
return { ok: false, error: "Montant invalide." };
}
const periodMonth = new Date(periodMonthISO);
if (Number.isNaN(periodMonth.getTime())) {
return { ok: false, error: "Période invalide." };
}
const res = await createPayoutMark({
providerId,
periodMonth,
amount,
reference,
paidByEmail: actor,
});
if (!res.ok) return res;
await recordAudit({
scope: "admin.payouts",
event: res.alreadyExists ? "payout.already_marked" : "payout.mark",
target: providerId,
actorEmail: actor,
details: {
periodMonth: periodMonth.toISOString().slice(0, 7),
amount,
reference,
},
});
// Notif provider best-effort (n'envoie que si on a un contactEmail)
if (!res.alreadyExists) {
try {
const provider = await prisma.rentalProvider.findUnique({
where: { id: providerId },
select: { name: true, contactEmail: true },
});
if (provider?.contactEmail) {
await sendPayoutSent(
provider.contactEmail,
provider.name,
periodMonth,
amount.toFixed(2),
reference,
);
}
} catch (e) {
console.error("[payouts] email send failed:", e instanceof Error ? e.message : e);
}
}
revalidatePath("/admin/payouts");
return { ok: true, alreadyExists: res.alreadyExists };
}
export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const periodMonth = new Date(periodMonthISO);
if (Number.isNaN(periodMonth.getTime())) return;
await deletePayoutMark(providerId, periodMonth);
await recordAudit({
scope: "admin.payouts",
event: "payout.unmark",
target: providerId,
actorEmail: actor,
details: { periodMonth: periodMonth.toISOString().slice(0, 7) },
});
revalidatePath("/admin/payouts");
}

View file

@ -0,0 +1,155 @@
import Link from "next/link";
import { formatMonth, listProviderPayouts } from "@/lib/payouts";
import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions";
import { MarkPaidForm } from "./_components/MarkPaidForm";
export const dynamic = "force-dynamic";
export const metadata = { title: "Reversements prestataires — Karbé admin" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function PayoutsAdminPage() {
const payouts = await listProviderPayouts({ monthsBack: 6 });
// Group by month
const byMonth = new Map<number, typeof payouts>();
for (const p of payouts) {
const k = p.periodMonth.getTime();
if (!byMonth.has(k)) byMonth.set(k, []);
byMonth.get(k)!.push(p);
}
// Globals
const totalDue = payouts
.filter((p) => !p.paid && p.netAmount > 0)
.reduce((s, p) => s + p.netAmount, 0);
const totalPaid = payouts
.filter((p) => p.paid)
.reduce((s, p) => s + (p.paid!.amount), 0);
return (
<div className="mx-auto max-w-6xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Reversements prestataires</h1>
<p className="mt-1 text-sm text-zinc-500">
Le marketplace encaisse centralisé sur System D ; voici les montants à reverser à chaque
prestataire pour les locations matériel des 6 derniers mois. System D n&apos;apparaît pas
dans la liste (commission 0 %).
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="À payer" value={fmtEur(totalDue)} highlight />
<KpiCard label="Déjà payé" value={fmtEur(totalPaid)} />
<KpiCard label="Mois affichés" value={`${byMonth.size}`} />
</section>
{Array.from(byMonth.entries())
.sort((a, b) => b[0] - a[0])
.map(([periodTs, rows]) => {
const period = new Date(periodTs);
const monthDue = rows
.filter((r) => !r.paid && r.netAmount > 0)
.reduce((s, r) => s + r.netAmount, 0);
return (
<section
key={periodTs}
className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm"
>
<header className="flex items-baseline justify-between border-b border-zinc-100 px-4 py-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
{formatMonth(period)}
</h2>
<span className="text-xs text-zinc-500">
Reste à payer ce mois :{" "}
<span className="font-mono font-semibold text-zinc-900">{fmtEur(monthDue)}</span>
</span>
</header>
<table className="w-full text-sm">
<thead className="border-b border-zinc-100 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-3 py-1.5 text-left font-semibold">Prestataire</th>
<th className="px-3 py-1.5 text-right font-semibold">Résa</th>
<th className="px-3 py-1.5 text-right font-semibold">CA brut</th>
<th className="px-3 py-1.5 text-right font-semibold">Commission</th>
<th className="px-3 py-1.5 text-right font-semibold">Net </th>
<th className="px-3 py-1.5 text-right font-semibold">Statut</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows
.sort((a, b) => b.netAmount - a.netAmount)
.map((p) => (
<tr key={`${p.providerId}-${periodTs}`} className="hover:bg-zinc-50">
<td className="px-3 py-1.5">
<Link
href={`/admin/rental-providers/${p.providerId}`}
className="text-zinc-900 hover:underline"
>
{p.providerName}
</Link>
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{p.bookingsCount}
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{fmtEur(p.grossAmount)}
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{fmtEur(p.commission)}
</td>
<td className="px-3 py-1.5 text-right font-mono font-semibold text-zinc-900">
{fmtEur(p.netAmount)}
</td>
<td className="px-3 py-1.5">
<div className="flex justify-end">
<MarkPaidForm
payout={p}
markAction={markPayoutPaidAction}
unmarkAction={unmarkPayoutPaidAction}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
})}
</div>
);
}
function KpiCard({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={
"rounded-lg border bg-white px-4 py-3 shadow-sm " +
(highlight ? "border-emerald-300 bg-emerald-50/40" : "border-zinc-200")
}
>
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div
className={
"mt-1 text-2xl font-semibold font-mono " +
(highlight ? "text-emerald-700" : "text-zinc-900")
}
>
{value}
</div>
</div>
);
}

View file

@ -7,9 +7,10 @@ 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: unknown) {
console.log(JSON.stringify({ scope: "admin.pirogue", event, target, actor, details, at: new Date().toISOString() }));
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({

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

@ -6,9 +6,10 @@ 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: unknown) {
console.log(JSON.stringify({ scope: "admin.reviews", event, target, actor, details, at: new Date().toISOString() }));
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({

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

@ -5,6 +5,7 @@ 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,
@ -14,8 +15,8 @@ const ROLE_VALUES = new Set<string>([
UserRole.ADMIN,
]);
async function audit(event: string, target: string, actor: string | null, details: unknown) {
console.log(JSON.stringify({ scope: "admin.users", event, target, actor, details, at: new Date().toISOString() }));
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) {

View file

@ -11,21 +11,28 @@ const patchSchema = z.object({
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 });
}
// L'admin édite la version FR par défaut (édition multi-langues à venir).
const existing = await prisma.contentPage.findUnique({
where: { slug_lang: { slug, lang: "fr" } },
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: "fr" } },
where: { slug_lang: { slug, lang } },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
@ -35,6 +42,7 @@ export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string
});
return NextResponse.json({
slug: updated.slug,
lang: updated.lang,
title: updated.title,
published: updated.published,
updatedAt: updated.updatedAt,

View file

@ -16,6 +16,8 @@ import {
parseIsoDate,
} from "@/lib/booking";
import { prisma } from "@/lib/prisma";
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
@ -27,6 +29,14 @@ type CreateBookingBody = {
};
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();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
@ -78,6 +88,9 @@ export async function POST(request: Request) {
ownerId: true,
capacity: 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({
data: {
carbetId: carbet.id,
@ -191,7 +210,7 @@ export async function POST(request: Request) {
endDate,
guestCount,
status: BookingStatus.PENDING,
amount: 0,
amount: computedAmount.toFixed(2),
currency: "EUR",
},
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 });
}

View file

@ -26,7 +26,11 @@ export async function POST(
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
if (
session.user.role !== UserRole.OWNER &&
session.user.role !== UserRole.ADMIN &&
session.user.role !== UserRole.CE_MANAGER
) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
@ -34,12 +38,15 @@ export async function POST(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
if (!canManageCarbet(session, carbet.ownerId)) {
if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}

View file

@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const INVITE_EXPIRY_GRACE_DAYS = 30;
const ABANDONED_PENDING_DAYS = 7;
/**
* GET /api/cron/cleanup
*
* Purge :
* - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court).
* - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED :
* status passé à CANCELLED (libère le créneau via cascade des
* Availabilities seulement si onDelete CASCADE ici on flip juste
* status pour conserver le log).
* - RentalBooking PENDING idem + delete RentalItemAvailability associée
* (libère le stock).
*
* Auth : Bearer CRON_TOKEN.
*/
export async function GET(req: Request) {
if (!isAuthorizedCronRequest(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const now = new Date();
const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000);
const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000);
// 1. Invites expirés (expiresAt < cutoff)
const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({
where: { expiresAt: { lt: inviteCutoff } },
});
// 2. Bookings carbet PENDING abandonnés
const abandonedBookings = await prisma.booking.findMany({
where: {
status: BookingStatus.PENDING,
paymentStatus: { not: PaymentStatus.SUCCEEDED },
createdAt: { lt: abandonedCutoff },
},
select: { id: true, carbetId: true },
});
let bookingsCancelled = 0;
if (abandonedBookings.length > 0) {
const { count } = await prisma.booking.updateMany({
where: { id: { in: abandonedBookings.map((b) => b.id) } },
data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED },
});
bookingsCancelled = count;
}
// 3. RentalBookings PENDING abandonnés + delete availability associée
const abandonedRentals = await prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.PENDING,
paymentStatus: { not: PaymentStatus.SUCCEEDED },
createdAt: { lt: abandonedCutoff },
},
select: { id: true },
});
let rentalsCancelled = 0;
let availabilityFreed = 0;
if (abandonedRentals.length > 0) {
const ids = abandonedRentals.map((r) => r.id);
const [rentalRes, availRes] = await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
status: RentalBookingStatus.CANCELLED,
paymentStatus: PaymentStatus.FAILED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
rentalsCancelled = rentalRes.count;
availabilityFreed = availRes.count;
}
await recordAudit({
scope: "cron",
event: "cron.cleanup.run",
target: null,
actorEmail: "system:cron",
details: {
invitesDeleted,
bookingsCancelled,
rentalsCancelled,
availabilityFreed,
},
});
return NextResponse.json({
ok: true,
invitesDeleted,
bookingsCancelled,
rentalsCancelled,
availabilityFreed,
});
}

View file

@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { isAuthorizedCronRequest } from "@/lib/cron-auth";
import { sendBookingReminder, sendRentalReminder } from "@/lib/email";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/cron/reminders
*
* Envoie des rappels J-1 (24h avant le début) pour :
* - Booking CONFIRMED dont startDate [now+22h, now+26h]
* - RentalBooking CONFIRMED idem
*
* Idempotent à l'échelle d'une journée : le filtre temporel narrow limite
* naturellement le risque de double-envoi (en pratique le cron tourne 1× par
* jour à heure fixe). Pour une garantie at-most-once stricte il faudrait
* stocker un flag `reminderSentAt` sur Booking/RentalBooking défensif
* mais pas critique pour v1.
*
* Auth : Bearer CRON_TOKEN.
*/
export async function GET(req: Request) {
if (!isAuthorizedCronRequest(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const now = new Date();
const from = new Date(now.getTime() + 22 * 60 * 60 * 1000);
const to = new Date(now.getTime() + 26 * 60 * 60 * 1000);
const [carbetBookings, rentalBookings] = await Promise.all([
prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
startDate: { gte: from, lt: to },
},
include: {
tenant: { select: { email: true, firstName: true } },
carbet: { select: { title: true, slug: true } },
},
}),
prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.CONFIRMED,
startDate: { gte: from, lt: to },
},
include: {
tenant: { select: { email: true, firstName: true } },
provider: { select: { name: true, contactEmail: true, contactPhone: true } },
},
}),
]);
let bookingSent = 0;
let bookingErrors = 0;
for (const b of carbetBookings) {
if (!b.tenant.email) continue;
try {
await sendBookingReminder(
b.tenant.email,
b.tenant.firstName,
b.id,
b.carbet.title,
b.startDate,
b.carbet.slug,
);
bookingSent++;
} catch (e) {
bookingErrors++;
console.error(
"[cron.reminders] booking email failed:",
b.id,
e instanceof Error ? e.message : e,
);
}
}
let rentalSent = 0;
let rentalErrors = 0;
for (const r of rentalBookings) {
if (!r.tenant.email) continue;
try {
await sendRentalReminder(
r.tenant.email,
r.tenant.firstName,
r.id,
r.provider.name,
r.startDate,
{ email: r.provider.contactEmail, phone: r.provider.contactPhone },
);
rentalSent++;
} catch (e) {
rentalErrors++;
console.error(
"[cron.reminders] rental email failed:",
r.id,
e instanceof Error ? e.message : e,
);
}
}
await recordAudit({
scope: "cron",
event: "cron.reminders.run",
target: null,
actorEmail: "system:cron",
details: {
window: { from: from.toISOString(), to: to.toISOString() },
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
},
});
return NextResponse.json({
ok: true,
window: { from: from.toISOString(), to: to.toISOString() },
booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors },
rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors },
});
}

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 { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
import { prisma } from "@/lib/prisma";
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() {
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 });
}

View file

@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { BookingStatus, CarbetStatus, UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Metrics publiques, agrégées (jamais de PII).
* Format JSON simple consommable par un script cron ou un dashboard léger.
*/
export async function GET() {
const now = new Date();
const last24h = new Date(now.getTime() - 86_400_000);
const last7d = new Date(now.getTime() - 7 * 86_400_000);
const last30d = new Date(now.getTime() - 30 * 86_400_000);
const [
carbetsPublished,
carbetsTotal,
bookings24h,
bookings7d,
bookings30d,
bookingsByStatus,
usersTotal,
usersByRole,
mediaTotal,
auditLast24h,
] = await Promise.all([
prisma.carbet.count({ where: { status: CarbetStatus.PUBLISHED } }),
prisma.carbet.count(),
prisma.booking.count({ where: { createdAt: { gte: last24h } } }),
prisma.booking.count({ where: { createdAt: { gte: last7d } } }),
prisma.booking.count({ where: { createdAt: { gte: last30d } } }),
prisma.booking.groupBy({
by: ["status"],
_count: { _all: true },
}),
prisma.user.count(),
prisma.user.groupBy({
by: ["role"],
_count: { _all: true },
}),
prisma.media.count(),
prisma.auditLog.count({ where: { createdAt: { gte: last24h } } }),
]);
return NextResponse.json({
generatedAt: now.toISOString(),
carbets: {
total: carbetsTotal,
published: carbetsPublished,
},
bookings: {
last24h: bookings24h,
last7d: bookings7d,
last30d: bookings30d,
byStatus: Object.fromEntries(
Object.values(BookingStatus).map((s) => [
s,
bookingsByStatus.find((b) => b.status === s)?._count._all ?? 0,
]),
),
},
users: {
total: usersTotal,
byRole: Object.fromEntries(
Object.values(UserRole).map((r) => [
r,
usersByRole.find((u) => u.role === r)?._count._all ?? 0,
]),
),
},
media: { total: mediaTotal },
audit: { last24h: auditLast24h },
});
}

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createPasswordResetToken } from "@/lib/password-reset";
import { prisma } from "@/lib/prisma";
import { sendPasswordReset } from "@/lib/email";
import { recordAudit } from "@/lib/admin/audit";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
const schema = z.object({
email: z.string().trim().toLowerCase().email(),
});
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
}
const parsed = schema.safeParse(body);
if (!parsed.success) {
// Réponse générique pour ne pas leak la validité du format à un attaquant.
return NextResponse.json({ ok: true });
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
select: { id: true, email: true, firstName: true, isActive: true },
});
if (user && user.isActive) {
const token = await createPasswordResetToken(user.id);
const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`;
sendPasswordReset(user.email, resetUrl).catch(() => {});
await recordAudit({
scope: "public.password",
event: "reset.request",
target: user.id,
actorEmail: user.email,
details: {},
});
}
// Réponse identique que l'email existe ou non (énumération-safe).
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { consumePasswordResetToken } from "@/lib/password-reset";
import { recordAudit } from "@/lib/admin/audit";
export const runtime = "nodejs";
const schema = z.object({
token: z.string().min(20).max(200),
password: z.string().min(8).max(200),
});
export async function POST(req: Request) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
}
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(" · ") },
{ status: 400 },
);
}
const result = await consumePasswordResetToken(parsed.data.token, parsed.data.password);
if (!result.ok) {
return NextResponse.json({ error: result.reason }, { status: 400 });
}
await recordAudit({
scope: "public.password",
event: "reset.success",
target: result.userId,
actorEmail: null,
details: {},
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
export const runtime = "nodejs";
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const media = await prisma.rentalItemMedia.findUnique({
where: { id },
select: { id: true, itemId: true, item: { select: { providerId: true } } },
});
if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
media.item.providerId,
session.user.organizationId,
);
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
await prisma.rentalItemMedia.delete({ where: { id } });
await recordAudit({
scope: "uploads",
event: "rental.media.delete",
target: id,
actorEmail: session.user.email ?? null,
details: { itemId: media.itemId },
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
export const runtime = "nodejs";
const schema = z.object({
itemId: 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 { itemId, orderedIds } = parsed.data;
const item = await prisma.rentalItem.findUnique({
where: { id: itemId },
select: { providerId: true },
});
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
const existing = await prisma.rentalItemMedia.findMany({
where: { itemId, id: { in: orderedIds } },
select: { id: true },
});
if (existing.length !== orderedIds.length) {
return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
}
await prisma.$transaction(
orderedIds.map((id, idx) =>
prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
),
);
// Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
const firstId = orderedIds[0];
const firstMedia = await prisma.rentalItemMedia.findUnique({
where: { id: firstId },
select: { s3Url: true, type: true },
});
if (firstMedia && firstMedia.type === "PHOTO") {
await prisma.rentalItem.update({
where: { id: itemId },
data: { imageUrl: firstMedia.s3Url },
});
}
await recordAudit({
scope: "uploads",
event: "rental.media.reorder",
target: itemId,
actorEmail: session.user.email ?? null,
details: { count: orderedIds.length },
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,193 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
PaymentStatus,
RentalBookingStatus,
UserRole,
} from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { canManageRentalProvider } from "@/lib/rental-access";
import { sendRentalCancelled } from "@/lib/email";
import { isStripeConfigured, getStripeClient } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { computeRentalRefund } from "@/lib/rental-refund";
export const runtime = "nodejs";
const CANCELLABLE_STATUSES: RentalBookingStatus[] = [
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
];
type Body = { reason?: string };
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
const { id } = await params;
const body: Body = await req.json().catch(() => ({}));
const reason = body.reason?.toString().trim().slice(0, 500) ?? null;
const rb = await prisma.rentalBooking.findUnique({
where: { id },
include: {
provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } },
tenant: { select: { id: true, email: true, firstName: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
if (!rb) {
return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
}
// Détecte qui annule pour l'auth + l'email :
// - tenant de la booking
// - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider)
// - admin
const role = session.user.role;
const isTenant = rb.tenantId === session.user.id;
const isAdmin = role === UserRole.ADMIN;
const canManage = await canManageRentalProvider(
session.user.id,
role,
rb.providerId,
session.user.organizationId,
);
const cancelledBy: "tenant" | "provider" | "admin" = isAdmin
? "admin"
: canManage
? "provider"
: "tenant";
if (!isAdmin && !canManage && !isTenant) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
if (!CANCELLABLE_STATUSES.includes(rb.status)) {
return NextResponse.json(
{ error: `Impossible d'annuler une réservation en statut ${rb.status}.` },
{ status: 409 },
);
}
// Calcule le remboursement selon la politique
const refund = computeRentalRefund({
startDate: rb.startDate,
itemsTotal: rb.itemsTotal,
depositTotal: rb.depositTotal,
});
// Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante
let stripeRefundId: string | null = null;
let stripeRefundError: string | null = null;
if (
rb.paymentStatus === PaymentStatus.SUCCEEDED &&
rb.stripeSessionId &&
isStripeConfigured() &&
refund.totalRefund.gt(0)
) {
try {
const stripe = getStripeClient();
const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, {
expand: ["payment_intent"],
});
const piId =
typeof sess.payment_intent === "string"
? sess.payment_intent
: sess.payment_intent?.id;
if (piId) {
const stripeRefund = await stripe.refunds.create({
payment_intent: piId,
amount: Math.round(Number(refund.totalRefund) * 100),
reason: "requested_by_customer",
});
stripeRefundId = stripeRefund.id;
}
} catch (e) {
stripeRefundError = e instanceof Error ? e.message : String(e);
console.error("[rental.cancel] Stripe refund failed:", stripeRefundError);
}
}
// Transaction : update booking + delete availability blocks
await prisma.$transaction([
prisma.rentalBooking.update({
where: { id },
data: {
status: RentalBookingStatus.CANCELLED,
paymentStatus:
rb.paymentStatus === PaymentStatus.SUCCEEDED
? PaymentStatus.REFUNDED
: PaymentStatus.FAILED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: id },
}),
]);
await recordAudit({
scope: "rental",
event: "rental.cancel",
target: id,
actorEmail: session.user.email ?? null,
details: {
cancelledBy,
reason,
policy: refund.policy,
itemsRefund: refund.itemsRefund.toString(),
depositRefund: refund.depositRefund.toString(),
totalRefund: refund.totalRefund.toString(),
stripeRefundId,
stripeRefundError,
},
});
// Email best-effort : tenant + provider
try {
await sendRentalCancelled(
rb.tenant.email,
rb.tenant.firstName,
rb.id,
rb.provider.name,
refund.totalRefund.toString(),
rb.currency,
refund.policyLabel,
cancelledBy,
);
if (rb.provider.contactEmail && cancelledBy !== "provider") {
await sendRentalCancelled(
rb.provider.contactEmail,
rb.provider.name,
rb.id,
rb.provider.name,
refund.totalRefund.toString(),
rb.currency,
refund.policyLabel,
cancelledBy,
);
}
} catch (e) {
console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e);
}
return NextResponse.json({
ok: true,
rentalBookingId: id,
refund: {
itemsRefund: refund.itemsRefund.toNumber(),
depositRefund: refund.depositRefund.toNumber(),
totalRefund: refund.totalRefund.toNumber(),
policy: refund.policy,
policyLabel: refund.policyLabel,
},
stripeRefundId,
stripeRefundError,
});
}

View file

@ -0,0 +1,361 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import {
sendRentalRequestedProvider,
sendRentalRequestedTenant,
} from "@/lib/email";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
getStripeClient,
isStripeConfigured,
toStripeAmountCents,
} from "@/lib/stripe";
export const runtime = "nodejs";
type LineInput = {
itemId: string;
qty: number;
startDate: Date;
endDate: Date;
nights: number;
};
function parseDateOnly(s: string): Date {
return new Date(s + "T00:00:00Z");
}
export async function POST() {
if (!(await isPluginEnabled("gear-rental"))) {
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
}
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
}
const jar = await cookies();
const cart = parseCart(jar.get(CART_COOKIE)?.value);
if (cart.items.length === 0) {
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
}
// Charge tous les items du panier
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = await prisma.rentalItem.findMany({
where: { id: { in: itemIds }, active: true },
include: {
provider: {
select: {
id: true,
name: true,
active: true,
approved: true,
commissionPct: true,
isSystemD: true,
},
},
},
});
const itemById = new Map(items.map((i) => [i.id, i]));
// Validations préliminaires : items valides + provider actif/approved
for (const entry of cart.items) {
const it = itemById.get(entry.itemId);
if (!it) {
return NextResponse.json(
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
{ status: 409 },
);
}
if (!it.provider.active || !it.provider.approved) {
return NextResponse.json(
{ error: `Prestataire ${it.provider.name} indisponible.` },
{ status: 409 },
);
}
if (entry.qty < 1 || entry.qty > it.totalQty) {
return NextResponse.json(
{ error: `Quantité invalide pour « ${it.name} ».` },
{ status: 400 },
);
}
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
return NextResponse.json(
{ error: `Dates invalides pour « ${it.name} ».` },
{ status: 400 },
);
}
}
// Groupe par provider
type Group = {
providerId: string;
providerName: string;
commissionPct: number;
lines: LineInput[];
itemsTotal: Prisma.Decimal;
depositTotal: Prisma.Decimal;
startDate: Date;
endDate: Date;
};
const groups = new Map<string, Group>();
for (const entry of cart.items) {
const it = itemById.get(entry.itemId)!;
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
let g = groups.get(it.provider.id);
if (!g) {
g = {
providerId: it.provider.id,
providerName: it.provider.name,
commissionPct: Number(it.provider.commissionPct),
lines: [],
itemsTotal: new Prisma.Decimal(0),
depositTotal: new Prisma.Decimal(0),
startDate: start,
endDate: end,
};
groups.set(it.provider.id, g);
}
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
g.itemsTotal = g.itemsTotal.add(lineSub);
g.depositTotal = g.depositTotal.add(lineDeposit);
if (start < g.startDate) g.startDate = start;
if (end > g.endDate) g.endDate = end;
}
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
let grandTotal = new Prisma.Decimal(0);
let grandDeposit = new Prisma.Decimal(0);
let rentalBookingIds: string[] = [];
try {
rentalBookingIds = await prisma.$transaction(async (tx) => {
const created: string[] = [];
for (const g of groups.values()) {
// Recheck stock disponible pour chaque ligne
for (const line of g.lines) {
const blocked = await tx.rentalItemAvailability.aggregate({
where: {
itemId: line.itemId,
startDate: { lt: line.endDate },
endDate: { gt: line.startDate },
},
_sum: { qty: true },
});
const item = itemById.get(line.itemId)!;
const used = Number(blocked._sum.qty ?? 0);
const free = item.totalQty - used;
if (line.qty > free) {
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
}
}
const commissionAmount = g.itemsTotal
.mul(g.commissionPct)
.div(100)
.toDecimalPlaces(2);
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
const rb = await tx.rentalBooking.create({
data: {
tenantId: session.user!.id!,
providerId: g.providerId,
startDate: g.startDate,
endDate: g.endDate,
status: RentalBookingStatus.PENDING,
paymentStatus: PaymentStatus.PENDING,
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
depositTotal: g.depositTotal.toDecimalPlaces(2),
commissionAmount,
amount,
currency: "EUR",
lines: {
create: g.lines.map((line) => {
const item = itemById.get(line.itemId)!;
const lineTotal = new Prisma.Decimal(item.pricePerDay)
.mul(line.qty)
.mul(line.nights)
.toDecimalPlaces(2);
return {
itemId: line.itemId,
qty: line.qty,
pricePerDay: new Prisma.Decimal(item.pricePerDay),
deposit: new Prisma.Decimal(item.deposit),
lineTotal,
};
}),
},
},
select: { id: true },
});
// Bloque les dispos
for (const line of g.lines) {
await tx.rentalItemAvailability.create({
data: {
itemId: line.itemId,
startDate: line.startDate,
endDate: line.endDate,
qty: line.qty,
reason: "RENTAL_BOOKING",
rentalBookingId: rb.id,
},
});
}
created.push(rb.id);
grandTotal = grandTotal.add(g.itemsTotal);
grandDeposit = grandDeposit.add(g.depositTotal);
}
return created;
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
{ status: 409 },
);
}
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
await recordAudit({
scope: "rental",
event: "rental.checkout.created",
target: rentalBookingIds.join(","),
actorEmail: session.user.email,
details: {
rentalBookingIds,
amount: totalAmount.toNumber(),
depositTotal: grandDeposit.toNumber(),
providers: Array.from(groups.keys()),
},
});
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
try {
const fullBookings = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true, contactEmail: true } },
lines: { include: { item: { select: { name: true } } } },
},
});
const tenantName = session.user.name ?? session.user.email!;
for (const rb of fullBookings) {
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
await sendRentalRequestedTenant(
session.user.email!,
tenantName,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
rb.amount.toString(),
rb.currency,
lineSummary,
);
if (rb.provider.contactEmail) {
await sendRentalRequestedProvider(
rb.provider.contactEmail,
rb.provider.name,
rb.id,
tenantName,
rb.startDate,
rb.endDate,
lineSummary,
);
}
}
} catch (e) {
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
}
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,
sameSite: "lax",
path: "/",
maxAge: 0,
});
// Stripe ou paiement différé
if (!isStripeConfigured()) {
return NextResponse.json(
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
{ status: 201 },
);
}
const appUrl = process.env.APP_URL;
if (!appUrl) {
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
}
// Une session Stripe avec une ligne par RentalBooking (agrégée)
const stripe = getStripeClient();
const bookingDetails = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
const line_items = bookingDetails.map((rb) => ({
quantity: 1,
price_data: {
currency: "eur",
unit_amount: toStripeAmountCents(Number(rb.amount)),
product_data: {
name: `Matériel — ${rb.provider.name}`,
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
},
},
}));
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
cancel_url: `${appUrl}/panier?payment=cancel`,
customer_email: session.user.email,
line_items,
metadata: {
type: "rental-bundle",
rentalBookingIds: rentalBookingIds.join(","),
},
});
await prisma.rentalBooking.updateMany({
where: { id: { in: rentalBookingIds } },
data: { stripeSessionId: checkoutSession.id },
});
return NextResponse.json(
{
rentalBookingIds,
totalAmount: totalAmount.toNumber(),
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getItemAvailability } from "@/lib/rentals-public";
import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
export const runtime = "nodejs";
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
if (!from || !to) {
return NextResponse.json(
{ error: "Paramètres from et to (YYYY-MM-DD) requis." },
{ status: 400 },
);
}
const start = normalizeUtcDayStart(from);
const end = normalizeUtcDayStart(to);
if (end <= start) {
return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
}
const calendar = await getItemAvailability(id, start, end);
return NextResponse.json({
itemId: id,
from: start.toISOString(),
to: end.toISOString(),
calendar,
});
}

199
src/app/api/signup/route.ts Normal file
View file

@ -0,0 +1,199 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { UserRole } from "@/generated/prisma/enums";
import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
import { slugify } from "@/lib/slug";
export const runtime = "nodejs";
const schema = z.object({
email: z.string().trim().toLowerCase().email().max(200),
password: z.string().min(8).max(200),
firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100),
phone: z.string().trim().max(40).optional().nullable(),
role: z
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
.default(UserRole.TOURIST),
providerName: z.string().trim().min(2).max(200).optional(),
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
orgName: z.string().trim().min(2).max(200).optional(),
inviteToken: z.string().trim().min(8).max(200).optional(),
});
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
}
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") },
{ status: 400 },
);
}
const data = parsed.data;
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
}
if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
}
// Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
// et on rattache à l'org du token (org déjà validée — pas de bannière pending).
let inviteOrgId: string | null = null;
if (data.inviteToken) {
const invite = await getOrgInviteByToken(data.inviteToken);
if (!invite) {
return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
}
if (invite.email && invite.email.toLowerCase() !== data.email) {
return NextResponse.json(
{ error: "Ce lien d'invitation est réservé à un autre email." },
{ status: 400 },
);
}
inviteOrgId = invite.organizationId;
}
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
if (existing) {
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
}
const passwordHash = await hashPassword(data.password);
// CE_MANAGER : transaction atomique User + Organization. Le slug est unique
// sur Organization → on retente avec un suffixe en cas de collision.
let createdProviderId: string | null = null;
let createdOrgId: string | null = null;
let user: { id: string; email: string; role: UserRole };
if (inviteOrgId) {
// Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: UserRole.CE_MEMBER,
organizationId: inviteOrgId,
isActive: true,
},
select: { id: true, email: true, role: true },
});
createdOrgId = inviteOrgId;
await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
} else if (data.role === UserRole.CE_MANAGER) {
const orgName = data.orgName!.trim();
const baseSlug = slugify(orgName);
const result = await prisma.$transaction(async (tx) => {
// Trouve un slug libre
let candidate = baseSlug || "ce";
let suffix = 1;
for (;;) {
const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
if (!exists) break;
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
// candidate now holds a free slug
const org = await tx.organization.create({
data: {
name: orgName,
slug: candidate,
contactEmail: data.email,
approved: false,
},
select: { id: true },
});
const u = await tx.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: UserRole.CE_MANAGER,
organizationId: org.id,
isActive: true,
},
select: { id: true, email: true, role: true },
});
return { user: u, orgId: org.id };
});
user = result.user;
createdOrgId = result.orgId;
sendNewCeRequest(orgName, user.email).catch(() => {});
} else {
user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone?.trim() || null,
role: data.role,
isActive: true,
},
select: { id: true, email: true, role: true },
});
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
const provider = await prisma.rentalProvider.create({
data: {
name: data.providerName,
isSystemD: false,
managedByUserId: user.id,
contactEmail: user.email,
contactPhone: data.phone?.trim() || null,
rivers: data.providerRivers ?? [],
commissionPct: 10, // valeur par défaut, ajustable par admin
active: true,
approved: false,
},
select: { id: true, name: true },
});
createdProviderId = provider.id;
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
}
}
await recordAudit({
scope: "public.signup",
event: "user.create",
target: user.id,
actorEmail: user.email,
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
});
sendSignupWelcome(user.email, data.firstName).catch(() => {});
return NextResponse.json({
ok: true,
userId: user.id,
providerId: createdProviderId,
organizationId: createdOrgId,
});
}

View file

@ -4,9 +4,11 @@ import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
import { sendRentalConfirmed } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
@ -51,6 +53,43 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
if (type === "rental-bundle") {
const idsRaw = session.metadata?.rentalBookingIds;
if (!idsRaw) return;
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return;
await prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: RentalBookingStatus.CONFIRMED,
},
});
try {
const rentals = await prisma.rentalBooking.findMany({
where: { id: { in: ids } },
include: {
provider: { select: { name: true } },
tenant: { select: { email: true, firstName: true } },
},
});
for (const rb of rentals) {
if (!rb.tenant.email) continue;
await sendRentalConfirmed(
rb.tenant.email,
rb.tenant.firstName ?? rb.tenant.email,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
);
}
} catch (e) {
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
}
return;
}
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
@ -79,6 +118,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
if (rentalIdsRaw) {
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
// Marque les paiements échoués + libère les blocages de dispo
await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.FAILED,
status: RentalBookingStatus.CANCELLED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
}
}
if (!bookingId) {
return;
}

View file

@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { MediaType, UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { classifyMime } from "@/lib/uploads";
import { recordAudit } from "@/lib/admin/audit";
import { generateImageVariants } from "@/lib/variants-server";
export const runtime = "nodejs";
const schema = z.object({
carbetId: z.string().min(1),
s3Key: z.string().min(5).max(500),
s3Url: z.string().url(),
mime: z.string().min(3).max(100),
});
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: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
}
const kind = classifyMime(parsed.data.mime);
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
const carbet = await prisma.carbet.findUnique({
where: { id: parsed.data.carbetId },
select: { id: true, ownerId: true },
});
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
const isOwner = carbet.ownerId === session.user.id;
const isAdmin = session.user.role === UserRole.ADMIN;
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère.
if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) {
return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 });
}
const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } });
const media = await prisma.media.create({
data: {
carbetId: carbet.id,
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
s3Key: parsed.data.s3Key,
s3Url: parsed.data.s3Url,
sortOrder: existingCount,
},
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
});
await recordAudit({
scope: "uploads",
event: "media.finalize",
target: media.id,
actorEmail: session.user.email ?? null,
details: { carbetId: carbet.id, kind },
});
// Génération des variantes responsives (best-effort, n'échoue pas la requête).
// L'utilisateur attend quelques secondes mais l'expérience derrière est bien meilleure.
try {
const variants = await generateImageVariants({
originalS3Key: parsed.data.s3Key,
mime: parsed.data.mime,
});
if (!variants.skipped) {
const okCount = variants.results.filter((r) => r.ok).length;
await recordAudit({
scope: "uploads",
event: "media.variants",
target: media.id,
actorEmail: session.user.email ?? null,
details: { generated: okCount, total: variants.results.length },
});
}
} catch (e) {
console.error("[uploads] variants generation error:", e);
}
return NextResponse.json({ media });
}

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 { presignCarbetUpload } from "@/lib/uploads";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
const schema = z.object({
carbetId: z.string().min(1),
mime: z.string().min(3).max(100),
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
});
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "presign", 60_000, 60);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429 },
);
}
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: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
}
const carbet = await prisma.carbet.findUnique({
where: { id: parsed.data.carbetId },
select: { id: true, ownerId: true },
});
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
const isOwner = carbet.ownerId === session.user.id;
const isAdmin = session.user.role === UserRole.ADMIN;
if (!isOwner && !isAdmin) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const result = await presignCarbetUpload({
carbetId: carbet.id,
mime: parsed.data.mime,
sizeBytes: parsed.data.sizeBytes,
});
if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return NextResponse.json(result);
}

View file

@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { MediaType } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
import { classifyMime } from "@/lib/uploads";
import { generateImageVariants } from "@/lib/variants-server";
export const runtime = "nodejs";
const schema = z.object({
itemId: z.string().min(1),
s3Key: z.string().min(5).max(500),
s3Url: z.string().url(),
mime: z.string().min(3).max(100),
});
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: parsed.error.issues[0]?.message ?? "Payload invalide" },
{ status: 400 },
);
}
const kind = classifyMime(parsed.data.mime);
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
const item = await prisma.rentalItem.findUnique({
where: { id: parsed.data.itemId },
select: { id: true, providerId: true },
});
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
}
const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
const media = await prisma.rentalItemMedia.create({
data: {
itemId: item.id,
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
s3Key: parsed.data.s3Key,
s3Url: parsed.data.s3Url,
sortOrder: existingCount,
},
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
});
// Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
// avec les listings (RentalItemCard, /carbets/[slug] panel).
if (existingCount === 0 && kind === "photo") {
await prisma.rentalItem.update({
where: { id: item.id },
data: { imageUrl: parsed.data.s3Url },
});
}
await recordAudit({
scope: "uploads",
event: "rental.media.finalize",
target: media.id,
actorEmail: session.user.email ?? null,
details: { itemId: item.id, kind },
});
try {
const variants = await generateImageVariants({
originalS3Key: parsed.data.s3Key,
mime: parsed.data.mime,
});
if (!variants.skipped) {
const okCount = variants.results.filter((r) => r.ok).length;
await recordAudit({
scope: "uploads",
event: "rental.media.variants",
target: media.id,
actorEmail: session.user.email ?? null,
details: { generated: okCount, total: variants.results.length },
});
}
} catch (e) {
console.error("[rental-uploads] variants generation error:", e);
}
return NextResponse.json({ media });
}

View file

@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { canManageRentalProvider } from "@/lib/rental-access";
import { rateLimitRequest } from "@/lib/rate-limit";
import { presignRentalItemUpload } from "@/lib/uploads";
export const runtime = "nodejs";
const schema = z.object({
itemId: z.string().min(1),
mime: z.string().min(3).max(100),
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
});
export async function POST(req: Request) {
const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
if (!rl.ok) {
return NextResponse.json(
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
{ status: 429 },
);
}
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: parsed.error.issues[0]?.message ?? "Payload invalide" },
{ status: 400 },
);
}
const item = await prisma.rentalItem.findUnique({
where: { id: parsed.data.itemId },
select: { id: true, providerId: true },
});
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
const allowed = await canManageRentalProvider(
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const result = await presignRentalItemUpload({
itemId: item.id,
mime: parsed.data.mime,
sizeBytes: parsed.data.sizeBytes,
});
if ("error" in result) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return NextResponse.json(result);
}

View file

@ -0,0 +1,105 @@
import Link from "next/link";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
type Props = {
river: string;
capacity: number;
};
const EMOJI: Record<string, string> = {
SLEEP: "💤",
NAVIGATION: "🛶",
FISHING: "🎣",
COOKING: "🍳",
SAFETY: "🦺",
};
export async function CompleteYourStay({ river, capacity }: Props) {
if (!(await isPluginEnabled("gear-rental"))) return null;
const providers = await prisma.rentalProvider.findMany({
where: {
active: true,
approved: true,
OR: [
{ isSystemD: true },
{ rivers: { has: river } },
],
},
select: {
id: true,
items: {
where: { active: true },
orderBy: [{ category: "asc" }, { pricePerDay: "asc" }],
take: 24,
select: {
id: true,
name: true,
category: true,
imageUrl: true,
pricePerDay: true,
provider: { select: { name: true, isSystemD: true } },
},
},
},
});
const items = providers.flatMap((p) => p.items).slice(0, 9);
if (items.length === 0) return null;
return (
<section className="my-8 rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<header className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-emerald-900">
Compléter votre séjour
</h2>
<p className="text-xs text-emerald-800">
Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river},
pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des
prestataires locaux.
</p>
</div>
<Link href="/materiel" className="text-xs font-semibold text-emerald-800 hover:underline">
Voir tout
</Link>
</header>
<ul className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{items.map((it) => (
<li
key={it.id}
className="overflow-hidden rounded-md border border-emerald-100 bg-white shadow-sm"
>
<Link href={`/materiel/${it.id}`} className="block">
<div className="flex aspect-video items-center justify-center bg-emerald-50 text-3xl">
{it.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.imageUrl} alt={it.name} className="h-full w-full object-cover" />
) : (
<span>{EMOJI[it.category] ?? "🎒"}</span>
)}
</div>
<div className="px-2.5 py-1.5">
<p className="truncate text-xs font-semibold text-zinc-900">{it.name}</p>
<div className="flex items-center justify-between text-[10px] text-zinc-500">
<span>{RENTAL_CATEGORY_LABEL[it.category]}</span>
<span className="font-mono font-semibold text-emerald-700">
{Number(it.pricePerDay).toFixed(0)} /j
</span>
</div>
{it.provider.isSystemD ? (
<span className="mt-1 inline-block rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
</Link>
</li>
))}
</ul>
</section>
);
}

View file

@ -12,10 +12,16 @@ import {
import { MediaType, UserRole } from "@/generated/prisma/enums";
import { formatAverageRating } from "@/lib/reviews";
import { isStripeConfigured } from "@/lib/stripe";
import { BookingForm } from "../_components/booking-form";
import { CompleteYourStay } from "./_components/CompleteYourStay";
import { CarbetGallery } from "../_components/carbet-gallery";
import { CarbetMap } from "../_components/carbet-map";
import { ReviewsSection } from "../_components/reviews-section";
import { StarRating } from "../_components/star-rating";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
import { OperationalBadges } from "@/components/OperationalBadges";
import { StayConstraints } from "@/components/StayConstraints";
import { PirogueTransportBlock } from "@/components/PirogueTransportBlock";
@ -109,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) {
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
</p>
{carbet.organizations.length > 0 ? (
<p className="mt-1 text-xs text-zinc-500">
Géré par le CE{" "}
{carbet.organizations.map((o, i) => (
<span key={o.id}>
<strong className="text-zinc-700">{o.name}</strong>
{i < carbet.organizations.length - 1 ? ", " : ""}
</span>
))}
</p>
) : null}
{carbet.reviewStats.count > 0 &&
carbet.reviewStats.averageRating !== null ? (
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">
@ -127,6 +144,20 @@ export default async function PublicCarbetPage({ params }: PageProps) {
<CarbetGallery title={carbet.title} media={carbet.media} />
</section>
<section className="mt-6">
<h2 className="mb-3 text-base font-semibold uppercase tracking-wider text-zinc-500">
Critères opérationnels
</h2>
<OperationalBadges
roadAccess={carbet.roadAccess}
capacity={carbet.capacity}
electricity={carbet.electricity}
gsmAtCarbet={carbet.gsmAtCarbet}
gsmExitDistanceKm={carbet.gsmExitDistanceKm}
variant="full"
/>
</section>
<div className="mt-10 grid gap-10 lg:grid-cols-3">
<div className="lg:col-span-2">
<section>
@ -143,6 +174,25 @@ export default async function PublicCarbetPage({ params }: PageProps) {
provider={carbet.pirogueProvider}
/>
<section className="mt-10">
<h2 className="text-xl font-semibold text-zinc-900">
se trouve ce carbet
</h2>
<p className="mt-1 text-sm text-zinc-600">
Fleuve <strong>{carbet.river}</strong> · embarquement à{" "}
<strong>{carbet.embarkPoint}</strong>
</p>
<div className="mt-3">
<CarbetMap
latitude={Number(carbet.latitude)}
longitude={Number(carbet.longitude)}
title={carbet.title}
river={carbet.river}
embarkPoint={carbet.embarkPoint}
/>
</div>
</section>
{carbet.amenities.length > 0 ? (
<section className="mt-10">
<h2 className="text-xl font-semibold text-zinc-900">
@ -226,13 +276,21 @@ export default async function PublicCarbetPage({ params }: PageProps) {
</dl>
</div>
<p className="rounded-md bg-emerald-50 px-3 py-2 text-xs text-emerald-800">
La réservation en ligne arrive bientôt. En attendant, contactez
l&apos;équipe Karbé pour organiser votre séjour.
</p>
<BookingForm
carbetId={carbet.id}
slug={carbet.slug}
nightlyPrice={Number(carbet.nightlyPrice)}
capacity={carbet.capacity}
minStayNights={carbet.minStayNights}
maxStayNights={carbet.maxStayNights}
isAuthenticated={Boolean(viewerId)}
stripeEnabled={isStripeConfigured()}
/>
</aside>
</div>
<CompleteYourStay river={carbet.river} capacity={carbet.capacity} />
<ReviewsSection
stats={carbet.reviewStats}
reviews={carbet.reviews}

View file

@ -0,0 +1,245 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MiniCalendar } from "./mini-calendar";
type Props = {
carbetId: string;
slug: string;
nightlyPrice: number;
capacity: number;
minStayNights: number | null;
maxStayNights: number | null;
isAuthenticated: boolean;
stripeEnabled: boolean;
};
function todayPlus(n: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
}
function diffDays(a: string, b: string): number {
if (!a || !b) return 0;
const da = new Date(a + "T00:00:00Z").getTime();
const db = new Date(b + "T00:00:00Z").getTime();
return Math.round((db - da) / 86400000);
}
export function BookingForm({
carbetId,
slug,
nightlyPrice,
capacity,
minStayNights,
maxStayNights,
isAuthenticated,
stripeEnabled,
}: Props) {
const router = useRouter();
const [startDate, setStartDate] = useState<string | null>(null);
const [endDate, setEndDate] = useState<string | null>(null);
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [blockedDates, setBlockedDates] = useState<Set<string>>(new Set());
// Fetch availability sur les 90 prochains jours pour griser/avertir.
useEffect(() => {
const ctrl = new AbortController();
const from = todayPlus(0);
const to = todayPlus(90);
fetch(`/api/carbets/${carbetId}/availability?from=${from}&to=${to}`, { signal: ctrl.signal })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (!j?.calendar) return;
const blocked = new Set<string>();
for (const d of j.calendar as { date: string; isAvailable: boolean }[]) {
if (!d.isAvailable) blocked.add(d.date);
}
setBlockedDates(blocked);
})
.catch(() => {});
return () => ctrl.abort();
}, [carbetId]);
const nights = useMemo(
() => (startDate && endDate ? Math.max(0, diffDays(startDate, endDate)) : 0),
[startDate, endDate],
);
const total = nights * nightlyPrice;
const minN = minStayNights ?? 1;
const maxN = maxStayNights ?? 365;
const datesSelected = Boolean(startDate && endDate);
const nightsOk = datesSelected && nights >= minN && nights <= maxN;
const guestOk = guestCount >= 1 && guestCount <= capacity;
const canSubmit = nightsOk && guestOk && !busy;
async function submit() {
if (!isAuthenticated) {
const next = `/carbets/${slug}`;
router.push(`/connexion?next=${encodeURIComponent(next)}`);
return;
}
setBusy(true);
setError(null);
try {
if (stripeEnabled) {
// Checkout Stripe : crée la résa + une session Checkout, redirige le user.
const res = await fetch("/api/stripe/checkout/booking", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
carbetId,
startDate,
endDate,
guestCount,
amount: nights * nightlyPrice,
currency: "EUR",
}),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.error || `Erreur ${res.status}`);
}
if (json.checkoutUrl) {
window.location.assign(json.checkoutUrl);
return;
}
// Fallback si pas d'URL retournée → page de la résa créée.
router.push(`/reservations/${json.bookingId ?? ""}`);
return;
}
// Pas de Stripe configuré → flux direct, résa en PENDING manuel.
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ carbetId, startDate, endDate, guestCount }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.error || `Erreur ${res.status}`);
}
router.push(`/reservations/${json.id ?? json.booking?.id ?? ""}`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex items-baseline justify-between">
<div>
<span className="text-2xl font-semibold text-zinc-900">{nightlyPrice.toFixed(0)} </span>
<span className="ml-1 text-sm text-zinc-500">/ nuit</span>
</div>
<span className="text-xs text-zinc-500">jusqu&apos;à {capacity} voyageurs</span>
</div>
<MiniCalendar
startDate={startDate}
endDate={endDate}
blockedDates={blockedDates}
onChange={(s, e) => {
setStartDate(s);
setEndDate(e);
setError(null);
}}
/>
{datesSelected ? (
<div className="flex items-center justify-between rounded-md bg-zinc-50 px-3 py-1.5 text-xs text-zinc-700">
<span>
<strong>{startDate}</strong> <strong>{endDate}</strong>
</span>
<button
type="button"
onClick={() => {
setStartDate(null);
setEndDate(null);
}}
className="text-zinc-500 hover:text-zinc-900"
>
Réinitialiser
</button>
</div>
) : null}
<label className="block text-sm">
<span className="text-xs text-zinc-500">Voyageurs</span>
<input
type="number"
min={1}
max={capacity}
value={guestCount}
onChange={(e) => setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
{datesSelected ? (
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
<div className="flex justify-between">
<span>
{nightlyPrice.toFixed(0)} × {nights} nuit{nights > 1 ? "s" : ""}
</span>
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} </span>
</div>
<div className="flex justify-between text-base font-semibold text-zinc-900">
<span>Total</span>
<span className="font-mono">{total.toFixed(2)} </span>
</div>
</div>
) : null}
{datesSelected && !nightsOk ? (
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
Séjour entre {minN} et {maxN} nuits requis.
</div>
) : null}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
) : null}
<button
type="button"
onClick={submit}
disabled={!canSubmit}
className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{busy
? "Envoi…"
: !isAuthenticated
? "Se connecter pour réserver"
: stripeEnabled
? "Payer et réserver"
: "Réserver"}
</button>
{!isAuthenticated ? (
<p className="text-center text-xs text-zinc-500">
Pas encore de compte ?{" "}
<Link href={`/inscription?next=${encodeURIComponent(`/carbets/${slug}`)}`} className="text-zinc-900 underline">
Créer un compte
</Link>
</p>
) : null}
<p className="text-center text-[11px] text-zinc-500">
{stripeEnabled
? "Vous serez redirigé vers Stripe pour le paiement sécurisé."
: "Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation."}
</p>
</div>
);
}

View file

@ -3,7 +3,9 @@ import Link from "next/link";
import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
import { formatAverageRating } from "@/lib/reviews";
import { buildSrcSet } from "@/lib/image-variants";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
import { OperationalBadges } from "@/components/OperationalBadges";
import { StayConstraints } from "@/components/StayConstraints";
import { StarRating } from "./star-rating";
@ -14,13 +16,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
<article className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md">
<Link href={href} className="relative block aspect-[4/3] bg-zinc-100">
{carbet.coverUrl ? (
// Use a plain <img> here — uploaded media URLs come from MinIO/S3 and
// don't go through next/image's optimizer in this environment.
// eslint-disable-next-line @next/next/no-img-element
<img
src={carbet.coverUrl}
srcSet={buildSrcSet(carbet.coverUrl)}
sizes="(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
alt={`Photo de ${carbet.title}`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
@ -39,9 +42,18 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
<AccessTypeBadge accessType={carbet.accessType} />
</div>
<p className="mt-1 text-sm text-zinc-600">
Fleuve {carbet.river} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
Fleuve {carbet.river}
</p>
<div className="mt-2">
<OperationalBadges
roadAccess={carbet.roadAccess}
capacity={carbet.capacity}
electricity={carbet.electricity}
gsmAtCarbet={carbet.gsmAtCarbet}
gsmExitDistanceKm={carbet.gsmExitDistanceKm}
variant="compact"
/>
</div>
<div className="mt-1 flex flex-wrap gap-1">
<StayConstraints
minNights={carbet.minStayNights}

View file

@ -1,14 +1,46 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { PublicCarbetMedia } from "@/lib/carbet-public";
import { MediaType } from "@/generated/prisma/enums";
import { buildSrcSet } from "@/lib/image-variants";
type Props = {
title: string;
media: PublicCarbetMedia[];
};
// SSR-friendly gallery: shows a cover (photo or video) plus a strip of
// secondary media. No client component — all native HTML controls.
/**
* Galerie publique : grille de vignettes ; clic = lightbox plein écran avec
* navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe.
*/
export function CarbetGallery({ title, media }: Props) {
const [active, setActive] = useState<number | null>(null);
const close = useCallback(() => setActive(null), []);
const next = useCallback(() => {
setActive((i) => (i === null ? null : (i + 1) % media.length));
}, [media.length]);
const prev = useCallback(() => {
setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length));
}, [media.length]);
useEffect(() => {
if (active === null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
}
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [active, close, next, prev]);
if (media.length === 0) {
return (
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
@ -17,57 +49,159 @@ export function CarbetGallery({ title, media }: Props) {
);
}
const [cover, ...rest] = media;
const cover = media[0];
const rest = media.slice(1);
const current = active === null ? null : media[active];
return (
<div className="space-y-3">
<figure className="overflow-hidden rounded-lg bg-zinc-100">
{cover.type === MediaType.VIDEO ? (
<video
src={cover.url}
controls
playsInline
preload="metadata"
className="aspect-[16/9] w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover.url}
alt={`Photo principale de ${title}`}
className="aspect-[16/9] w-full object-cover"
/>
)}
</figure>
<>
<div className="space-y-3">
<button
type="button"
onClick={() => setActive(0)}
className="block w-full overflow-hidden rounded-lg bg-zinc-100 transition hover:opacity-95"
aria-label="Ouvrir la photo principale en grand"
>
{cover.type === MediaType.VIDEO ? (
<video
src={cover.url}
controls
playsInline
preload="metadata"
className="aspect-[16/9] w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover.url}
srcSet={buildSrcSet(cover.url)}
sizes="(min-width: 768px) 800px, 100vw"
alt={`Photo principale de ${title}`}
fetchPriority="high"
decoding="async"
className="aspect-[16/9] w-full cursor-zoom-in object-cover"
/>
)}
</button>
{rest.length > 0 ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{rest.map((item) => (
<li
key={item.id}
className="overflow-hidden rounded-md bg-zinc-100"
>
{item.type === MediaType.VIDEO ? (
<video
src={item.url}
preload="metadata"
controls
playsInline
className="aspect-square w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
alt={`Média de ${title}`}
loading="lazy"
className="aspect-square w-full object-cover"
/>
)}
</li>
))}
</ul>
{rest.length > 0 ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{rest.map((item, idx) => (
<li key={item.id} className="overflow-hidden rounded-md bg-zinc-100">
<button
type="button"
onClick={() => setActive(idx + 1)}
className="block w-full"
aria-label="Ouvrir en grand"
>
{item.type === MediaType.VIDEO ? (
<video
src={item.url}
preload="metadata"
controls
playsInline
className="aspect-square w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
srcSet={buildSrcSet(item.url)}
sizes="(min-width: 640px) 200px, 50vw"
alt={`Média de ${title}`}
loading="lazy"
decoding="async"
className="aspect-square w-full cursor-zoom-in object-cover transition hover:scale-105"
/>
)}
</button>
</li>
))}
</ul>
) : null}
</div>
{current ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm"
onClick={close}
role="dialog"
aria-modal="true"
aria-label="Galerie photo"
>
<button
type="button"
onClick={close}
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
aria-label="Fermer"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6 L18 18 M6 18 L18 6" />
</svg>
</button>
{media.length > 1 ? (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
prev();
}}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
aria-label="Précédent"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 6 L9 12 L15 18" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
next();
}}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
aria-label="Suivant"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 6 L15 12 L9 18" />
</svg>
</button>
</>
) : null}
<div
className="max-h-[88vh] max-w-[92vw]"
onClick={(e) => e.stopPropagation()}
>
{current.type === MediaType.VIDEO ? (
<video
src={current.url}
controls
autoPlay
playsInline
className="max-h-[88vh] max-w-[92vw] object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.url}
srcSet={buildSrcSet(current.url)}
sizes="(min-width: 1200px) 1600px, 92vw"
alt={`Photo ${active! + 1} sur ${media.length} de ${title}`}
fetchPriority="high"
decoding="async"
className="max-h-[88vh] max-w-[92vw] object-contain"
/>
)}
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
{active! + 1} / {media.length}
</div>
</div>
) : null}
</div>
</>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Fix icône Leaflet (les paths par défaut pointent vers un CDN qui n'existe plus).
// On utilise un SVG inline en data URL.
const ICON = L.divIcon({
className: "karbe-leaflet-marker",
html: `
<div style="
width:32px;height:32px;
transform:translate(-50%,-100%);
display:flex;align-items:center;justify-content:center;
">
<svg viewBox="0 0 32 40" width="32" height="40" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
<circle cx="16" cy="15" r="5" fill="white"/>
</svg>
</div>
`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -36],
});
type Props = {
latitude: number;
longitude: number;
title: string;
river: string;
embarkPoint: string;
};
export function CarbetMapInner({ latitude, longitude, title, river, embarkPoint }: Props) {
const position: [number, number] = [latitude, longitude];
return (
<div className="overflow-hidden rounded-lg border border-zinc-200">
<MapContainer
center={position}
zoom={11}
scrollWheelZoom={false}
style={{ height: 280, width: "100%" }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position} icon={ICON}>
<Popup>
<strong>{title}</strong>
<br />
<span className="text-xs">Fleuve {river}</span>
<br />
<span className="text-xs text-zinc-600">Embarquement : {embarkPoint}</span>
<br />
<a
href={`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=14/${latitude}/${longitude}`}
target="_blank"
rel="noreferrer"
className="text-xs text-emerald-700 underline"
>
Ouvrir dans OpenStreetMap
</a>
</Popup>
</Marker>
</MapContainer>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
/**
* Carte interactive sur la fiche carbet Leaflet + OpenStreetMap.
*
* Chargée dynamiquement (ssr:false) car Leaflet manipule window.
*/
import dynamic from "next/dynamic";
const CarbetMapInner = dynamic(
() => import("./carbet-map-inner").then((m) => m.CarbetMapInner),
{
ssr: false,
loading: () => (
<div className="h-[280px] w-full animate-pulse rounded-lg bg-zinc-100" />
),
},
);
type Props = {
latitude: number;
longitude: number;
title: string;
river: string;
embarkPoint: string;
};
export function CarbetMap(props: Props) {
return <CarbetMapInner {...props} />;
}

View file

@ -0,0 +1,113 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import L, { LatLngBoundsExpression } from "leaflet";
import "leaflet/dist/leaflet.css";
import type { CatalogMapPoint } from "./catalog-map";
const ICON = L.divIcon({
className: "karbe-catalog-marker",
html: `
<div style="
width:28px;height:36px;
transform:translate(-50%,-100%);
display:flex;align-items:center;justify-content:center;
">
<svg viewBox="0 0 32 40" width="28" height="36" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0 C7 0 0 7 0 16 C0 26 16 40 16 40 C16 40 32 26 32 16 C32 7 25 0 16 0 Z"
fill="#059669" stroke="#064e3b" stroke-width="1.5"/>
<circle cx="16" cy="15" r="5" fill="white"/>
</svg>
</div>
`,
iconSize: [28, 36],
iconAnchor: [14, 36],
popupAnchor: [0, -32],
});
export function CatalogMapInner({ points }: { points: CatalogMapPoint[] }) {
const bounds = useMemo<LatLngBoundsExpression>(() => {
if (points.length === 0) {
// Centre par défaut sur la Guyane (Cayenne).
return [
[3.5, -54.5],
[5.5, -52.0],
];
}
const lats = points.map((p) => p.latitude);
const lngs = points.map((p) => p.longitude);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
// Padding 0.1°
return [
[minLat - 0.1, minLng - 0.1],
[maxLat + 0.1, maxLng + 0.1],
];
}, [points]);
return (
<div className="overflow-hidden rounded-lg border border-zinc-200">
<MapContainer
bounds={bounds}
scrollWheelZoom={false}
style={{ height: 360, width: "100%" }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{points.map((p) => (
<Marker key={p.id} position={[p.latitude, p.longitude]} icon={ICON}>
<Popup>
<div style={{ minWidth: 180 }}>
{p.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={p.coverUrl}
alt={p.title}
style={{
width: "100%",
height: 110,
objectFit: "cover",
borderRadius: 4,
marginBottom: 6,
}}
/>
) : null}
<strong>{p.title}</strong>
<br />
<span style={{ fontSize: 11, color: "#71717a" }}>
Fleuve {p.river}
</span>
<br />
<span style={{ fontSize: 13, fontWeight: 600 }}>
{Number(p.nightlyPrice).toFixed(0)}
</span>
<span style={{ fontSize: 11, color: "#71717a" }}> / nuit</span>
<br />
<Link
href={`/carbets/${p.slug}`}
style={{
display: "inline-block",
marginTop: 6,
color: "#059669",
fontWeight: 600,
textDecoration: "underline",
}}
>
Voir la fiche
</Link>
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}

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