Compare commits

..

No commits in common. "main" and "chore/admin-carbet-options-split" have entirely different histories.

173 changed files with 302 additions and 15907 deletions

View file

@ -1,59 +0,0 @@
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,30 +7,18 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "prisma generate",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
"postinstall": "prisma generate"
},
"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": {
@ -38,13 +26,11 @@
"@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",
"vitest": "^3.2.4"
"typescript": "^5.9.3"
}
}

View file

@ -1,22 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,9 +0,0 @@
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

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

View file

@ -1,15 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,112 +0,0 @@
-- 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

@ -13,7 +13,6 @@ enum UserRole {
CE_MEMBER
TOURIST
ADMIN
RENTAL_PROVIDER
}
enum CarbetStatus {
@ -98,13 +97,11 @@ 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[]
rentalProviders RentalProvider[]
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
@@index([organizationId])
@@index([role])
@ -127,13 +124,6 @@ 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?
@ -252,8 +242,7 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
review Review?
rentalBookings RentalBooking[]
review Review?
@@index([carbetId])
@@index([tenantId])
@ -337,196 +326,3 @@ 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?
contactEmail String?
contactPhone String?
rivers String[] @default([])
description String?
commissionPct Decimal @db.Decimal(5, 2) @default(0)
active Boolean @default(true)
approved Boolean @default(false)
approvedAt DateTime?
approvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
items RentalItem[]
rentalBookings RentalBooking[]
@@index([active, approved])
@@index([managedByUserId])
}
model RentalItem {
id String @id @default(cuid())
providerId String
category RentalCategory
name String
description String?
imageUrl String?
pricePerDay Decimal @db.Decimal(8, 2)
pricePerWeek Decimal? @db.Decimal(8, 2)
deposit Decimal @db.Decimal(8, 2) @default(0)
totalQty Int @default(1)
withMotor Boolean @default(false)
fuelIncluded Boolean @default(false)
requiresLicense Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
availabilities RentalItemAvailability[]
lines RentalLine[]
@@index([providerId])
@@index([category, active])
}
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.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -1,60 +0,0 @@
{
"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" }]
}
]
}

View file

@ -1,54 +0,0 @@
#!/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)"

View file

@ -1,60 +0,0 @@
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

@ -1,134 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
"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";
@ -124,7 +125,7 @@ export function MediaManager({ carbetId, media: initial }: { carbetId: string; m
</select>
</FormField>
</div>
{/* Le serveur calcule un s3Key déterministe à partir de l'URL si vide. */}
<input type="hidden" name="s3Key" value={`external/${Date.now()}`} />
{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

@ -7,7 +7,7 @@ import {
} from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaUploader } from "@/components/MediaUploader";
import { MediaManager } from "./_components/MediaManager";
import { StatusActions } from "./_components/StatusActions";
import { updateCarbetAction } from "../actions";
@ -61,21 +61,16 @@ export default async function EditCarbetPage({ params }: PageProps) {
</div>
</header>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Médias
</h2>
<MediaUploader
carbetId={carbet.id}
initialMedia={carbet.media.map((m) => ({
id: m.id,
type: m.type,
s3Key: m.s3Key,
s3Url: m.s3Url,
sortOrder: m.sortOrder,
}))}
/>
</section>
<MediaManager
carbetId={carbet.id}
media={carbet.media.map((m) => ({
id: m.id,
type: m.type,
s3Key: m.s3Key,
s3Url: m.s3Url,
sortOrder: m.sortOrder,
}))}
/>
<CarbetForm
owners={owners}
@ -92,12 +87,7 @@ 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,12 +18,7 @@ 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;
@ -193,66 +188,9 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
</div>
</section>
{/* Critères opérationnels */}
{/* Séjour */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Critères opérationnels
</h2>
<p className="mb-4 text-xs text-zinc-500">
Les 4 dealbreakers d&apos;un séjour en carbet guyanais. Indispensable pour les filtres recherche.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="🛣️ Accès route" hint="Praticabilité de l'accès depuis la route">
<select name="roadAccess" defaultValue={initial.roadAccess ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="ALL_YEAR">🛣 Toute saison</option>
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
<option value="NONE">🛶 Pirogue uniquement</option>
</select>
</FormField>
<FormField label="⚡ Électricité" hint="Comment est alimenté le carbet ?">
<select name="electricity" defaultValue={initial.electricity ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="EDF"> EDF / raccordé réseau</option>
<option value="GENERATOR_READY">🔌 Préinstallation groupe électrogène</option>
<option value="SOLAR"> Solaire</option>
<option value="NONE">🕯 Aucune électricité</option>
</select>
</FormField>
<FormField label="📶 Réseau GSM au carbet" hint="Téléphone capte directement sur place ?">
<select
name="gsmAtCarbet"
defaultValue={initial.gsmAtCarbet ? "yes" : "no"}
className={selectCls}
>
<option value="yes"> Oui, signal au carbet</option>
<option value="no"> Non, zone sans réseau</option>
</select>
</FormField>
<FormField
label="📵 Distance pour atteindre le réseau (km)"
hint="Si pas de réseau au carbet — sinon laisser vide"
>
<input
name="gsmExitDistanceKm"
type="number"
min={0}
max={50}
step="0.1"
defaultValue={initial.gsmExitDistanceKm?.toString() ?? ""}
placeholder="ex. 1.5"
className={inputCls}
/>
</FormField>
</div>
</section>
{/* Séjour & tarif */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour &amp; tarif</h2>
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<FormField label="Capacité" required hint="Voyageurs max">
<input
@ -265,17 +203,6 @@ 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,14 +5,11 @@ 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";
@ -29,18 +26,7 @@ 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(),
@ -65,11 +51,9 @@ function parseFromFormData(fd: FormData) {
if (typeof v === "string") obj[k] = v;
}
// Normalise les champs optionnels nullables
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].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;
}
@ -213,17 +197,23 @@ 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.
*/
async function audit(
event: string,
action: string,
entityId: string,
actor: string | null,
payload: Record<string, unknown>,
) {
await recordAudit({
scope: "admin.carbets",
event,
target: entityId,
actorEmail: actor,
details: payload,
});
console.log(
JSON.stringify({
audit: action,
actor,
entityId,
payload,
at: new Date().toISOString(),
}),
);
}

View file

@ -99,7 +99,6 @@ 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>
@ -110,7 +109,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
<tbody className="divide-y divide-zinc-100">
{carbets.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun carbet ne correspond aux filtres.
</td>
</tr>
@ -130,7 +129,6 @@ 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,7 +5,6 @@ import { useRouter } from "next/navigation";
type Page = {
slug: string;
lang: string;
title: string;
body: string;
category: string;
@ -26,14 +25,11 @@ export default function EditorForm({ page }: { page: Page }) {
setMsg(null);
setErr(null);
try {
const res = await fetch(
`/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, published }),
},
);
const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, published }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.error || `HTTP ${res.status}`);

View file

@ -2,90 +2,46 @@ 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 }>;
searchParams: Promise<{ lang?: string }>;
};
type PageProps = { params: Promise<{ slug: string }> };
function normalizeLang(v: string | undefined): string {
if (!v) return "fr";
const l = v.toLowerCase().trim();
return /^[a-z]{2}$/.test(l) ? l : "fr";
}
export default async function EditContentPage({ params, searchParams }: PageProps) {
export default async function EditContentPage({ params }: PageProps) {
await requireRole([UserRole.ADMIN]);
const { slug } = await params;
const sp = await searchParams;
const lang = normalizeLang(sp.lang);
const [row, siblings] = await Promise.all([
prisma.contentPage.findUnique({ where: { slug_lang: { slug, lang } } }),
prisma.contentPage.findMany({
where: { slug },
select: { lang: true, title: true, published: true, updatedAt: true },
orderBy: { lang: "asc" },
}),
]);
// 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" } },
});
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">
<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="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="mt-6">
<EditorForm page={page} />
</div>

View file

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

View file

@ -1,169 +0,0 @@
"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

@ -1,67 +0,0 @@
"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

@ -1,39 +0,0 @@
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

@ -1,137 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import Link from "next/link";
import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
import { KPICard } from "@/components/admin/KPICard";
@ -67,34 +66,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>
<Link href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<a href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Gérer les carbets
</Link>
</a>
</li>
<li>
<Link href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<a href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Voir les réservations
</Link>
</a>
</li>
<li>
<Link href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<a href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Éditer les pages
</Link>
</a>
</li>
<li>
<Link href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<a href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Activer / désactiver des plugins
</Link>
</a>
</li>
<li>
<Link href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<a href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Modérer les utilisateurs
</Link>
</a>
</li>
<li>
<Link href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
<a href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Paramètres
</Link>
</a>
</li>
</ul>
</section>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,86 +0,0 @@
"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

@ -1,83 +0,0 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
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">
<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

@ -1,132 +0,0 @@
"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

@ -1,129 +0,0 @@
"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

@ -1,31 +0,0 @@
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

@ -1,152 +0,0 @@
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

@ -1,120 +0,0 @@
"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

@ -1,136 +0,0 @@
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

@ -1,132 +0,0 @@
"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

@ -1,150 +0,0 @@
"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

@ -1,21 +0,0 @@
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

@ -1,149 +0,0 @@
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

@ -1,141 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

@ -1,171 +0,0 @@
"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

@ -1,100 +0,0 @@
"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

@ -1,100 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

@ -11,28 +11,21 @@ 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 } },
where: { slug_lang: { slug, lang: "fr" } },
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updated = await prisma.contentPage.update({
where: { slug_lang: { slug, lang } },
where: { slug_lang: { slug, lang: "fr" } },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}),
@ -42,7 +35,6 @@ 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,8 +16,6 @@ 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";
@ -29,14 +27,6 @@ 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 });
@ -88,9 +78,6 @@ export async function POST(request: Request) {
ownerId: true,
capacity: true,
status: true,
nightlyPrice: true,
title: true,
owner: { select: { email: true, firstName: true } },
},
});
@ -196,12 +183,6 @@ 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,
@ -210,7 +191,7 @@ export async function POST(request: Request) {
endDate,
guestCount,
status: BookingStatus.PENDING,
amount: computedAmount.toFixed(2),
amount: 0,
currency: "EUR",
},
select: {
@ -226,34 +207,5 @@ 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

@ -1,37 +0,0 @@
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

@ -1,61 +0,0 @@
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,101 +1,7 @@
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() {
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 },
);
return NextResponse.json({ status: "ok" });
}

View file

@ -1,103 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,78 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,77 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { UserRole } from "@/generated/prisma/enums";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
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]).default(UserRole.TOURIST),
});
export async function POST(req: Request) {
// 5 inscriptions max par IP par heure.
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;
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);
const 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 },
});
await recordAudit({
scope: "public.signup",
event: "user.create",
target: user.id,
actorEmail: user.email,
details: { role: user.role },
});
// Best-effort welcome email.
sendSignupWelcome(user.email, data.firstName).catch(() => {});
return NextResponse.json({ ok: true, userId: user.id });
}

View file

@ -1,89 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -12,15 +12,10 @@ 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 { 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";
@ -132,20 +127,6 @@ 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>
@ -162,25 +143,6 @@ 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">
@ -264,16 +226,10 @@ export default async function PublicCarbetPage({ params }: PageProps) {
</dl>
</div>
<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()}
/>
<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>
</aside>
</div>

View file

@ -1,245 +0,0 @@
"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,9 +3,7 @@ import Link from "next/link";
import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
import { formatAverageRating } from "@/lib/reviews";
import { 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";
@ -16,14 +14,13 @@ 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]"
/>
) : (
@ -42,18 +39,9 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
<AccessTypeBadge accessType={carbet.accessType} />
</div>
<p className="mt-1 text-sm text-zinc-600">
Fleuve {carbet.river}
Fleuve {carbet.river} · {carbet.capacity} voyageur
{carbet.capacity > 1 ? "s" : ""}
</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,46 +1,14 @@
"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[];
};
/**
* Galerie publique : grille de vignettes ; clic = lightbox plein écran avec
* navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe.
*/
// SSR-friendly gallery: shows a cover (photo or video) plus a strip of
// secondary media. No client component — all native HTML controls.
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">
@ -49,159 +17,57 @@ export function CarbetGallery({ title, media }: Props) {
);
}
const cover = media[0];
const rest = media.slice(1);
const current = active === null ? null : media[active];
const [cover, ...rest] = media;
return (
<>
<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>
<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>
{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>
{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>
) : null}
</>
</div>
);
}

View file

@ -1,74 +0,0 @@
"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

@ -1,31 +0,0 @@
"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

@ -1,113 +0,0 @@
"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>
);
}

View file

@ -1,29 +0,0 @@
"use client";
import dynamic from "next/dynamic";
const CatalogMapInner = dynamic(
() => import("./catalog-map-inner").then((m) => m.CatalogMapInner),
{
ssr: false,
loading: () => (
<div className="h-[360px] w-full animate-pulse rounded-lg bg-zinc-100" />
),
},
);
export type CatalogMapPoint = {
id: string;
slug: string;
title: string;
river: string;
nightlyPrice: string;
latitude: number;
longitude: number;
coverUrl: string | null;
};
export function CatalogMap({ points }: { points: CatalogMapPoint[] }) {
if (points.length === 0) return null;
return <CatalogMapInner points={points} />;
}

View file

@ -1,186 +0,0 @@
"use client";
import { useMemo, useState } from "react";
type Props = {
startDate: string | null;
endDate: string | null;
blockedDates: Set<string>;
onChange: (start: string | null, end: string | null) => void;
};
const MONTH_LABEL = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
];
const DOW_LABEL = ["L", "M", "M", "J", "V", "S", "D"];
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
function startOfMonth(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
function addMonths(d: Date, n: number): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
}
/** Génère la grille du mois : 6 semaines × 7 jours, en commençant un lundi. */
function monthGrid(monthStart: Date): (Date | null)[] {
const year = monthStart.getUTCFullYear();
const month = monthStart.getUTCMonth();
// Premier jour du mois — décale pour que la semaine commence un lundi (0=L, 6=D)
const firstDay = new Date(Date.UTC(year, month, 1));
const firstDow = (firstDay.getUTCDay() + 6) % 7;
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const cells: (Date | null)[] = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= lastDay; d++) {
cells.push(new Date(Date.UTC(year, month, d)));
}
while (cells.length % 7 !== 0) cells.push(null);
// Toujours 6 lignes pour éviter le saut de hauteur
while (cells.length < 42) cells.push(null);
return cells;
}
export function MiniCalendar({ startDate, endDate, blockedDates, onChange }: Props) {
const today = useMemo(() => {
const d = new Date();
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}, []);
const [viewMonth, setViewMonth] = useState<Date>(() => {
const ref = startDate ? new Date(startDate + "T00:00:00Z") : today;
return startOfMonth(ref);
});
const cells = useMemo(() => monthGrid(viewMonth), [viewMonth]);
const startISO = startDate;
const endISO = endDate;
function onClick(day: Date) {
const iso = isoDay(day);
if (day.getTime() < today.getTime()) return;
if (blockedDates.has(iso)) return;
// Aucune sélection ou les deux déjà posées → reset + nouvelle start
if (!startISO || (startISO && endISO)) {
onChange(iso, null);
return;
}
// Une seule (start) déjà sélectionnée
if (iso === startISO) {
onChange(null, null);
return;
}
if (iso < startISO) {
onChange(iso, null);
return;
}
// Vérifie qu'aucun jour intermédiaire n'est bloqué
const startMs = new Date(startISO + "T00:00:00Z").getTime();
const endMs = day.getTime();
for (let t = startMs; t < endMs; t += 86_400_000) {
const d = new Date(t).toISOString().slice(0, 10);
if (blockedDates.has(d)) {
// Tombe sur un jour bloqué → on resélectionne start
onChange(iso, null);
return;
}
}
onChange(startISO, iso);
}
const canGoBack = viewMonth > startOfMonth(today);
return (
<div className="rounded-md border border-zinc-200 bg-white p-2">
<header className="mb-1 flex items-center justify-between">
<button
type="button"
disabled={!canGoBack}
onClick={() => setViewMonth(addMonths(viewMonth, -1))}
className="rounded p-1 text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
aria-label="Mois précédent"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 6 L9 12 L15 18" />
</svg>
</button>
<span className="text-sm font-semibold text-zinc-900">
{MONTH_LABEL[viewMonth.getUTCMonth()]} {viewMonth.getUTCFullYear()}
</span>
<button
type="button"
onClick={() => setViewMonth(addMonths(viewMonth, 1))}
className="rounded p-1 text-zinc-600 hover:bg-zinc-100"
aria-label="Mois suivant"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 6 L15 12 L9 18" />
</svg>
</button>
</header>
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] uppercase tracking-wider text-zinc-400">
{DOW_LABEL.map((d, i) => (
<div key={i} className="py-0.5">
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{cells.map((cell, i) => {
if (!cell) return <div key={i} className="h-7" />;
const iso = isoDay(cell);
const isPast = cell.getTime() < today.getTime();
const isBlocked = blockedDates.has(iso);
const isStart = iso === startISO;
const isEnd = iso === endISO;
const inRange = startISO && endISO && iso > startISO && iso < endISO;
const isToday = iso === isoDay(today);
const disabled = isPast || isBlocked;
let cls =
"relative h-7 rounded text-xs flex items-center justify-center transition";
if (disabled) {
cls += " text-zinc-300 cursor-not-allowed";
if (isBlocked && !isPast) cls += " line-through";
} else if (isStart || isEnd) {
cls += " bg-emerald-600 text-white font-semibold cursor-pointer";
} else if (inRange) {
cls += " bg-emerald-100 text-emerald-900 cursor-pointer";
} else {
cls += " text-zinc-800 hover:bg-zinc-100 cursor-pointer";
if (isToday) cls += " ring-1 ring-zinc-400";
}
return (
<button
key={i}
type="button"
disabled={disabled}
onClick={() => onClick(cell)}
className={cls}
>
{cell.getUTCDate()}
</button>
);
})}
</div>
<p className="mt-2 text-[11px] text-zinc-500">
{!startISO
? "Choisissez votre date d'arrivée."
: !endISO
? "Choisissez votre date de départ."
: ""}
</p>
</div>
);
}

View file

@ -1,8 +1,6 @@
import Link from "next/link";
import type { CarbetSearchFilters } from "@/lib/carbet-search";
import { AMENITY_CATALOG } from "@/lib/amenities";
import { Electricity, RoadAccess } from "@/generated/prisma/enums";
type SearchFiltersProps = {
filters: CarbetSearchFilters;
@ -63,165 +61,18 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Voyageurs min</span>
<span className="font-medium text-zinc-700">Voyageurs</span>
<input
type="number"
name="capacity"
min={1}
max={100}
defaultValue={filters.capacity ?? ""}
placeholder="Au moins"
placeholder="Nombre min."
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Voyageurs max</span>
<input
type="number"
name="capacityMax"
min={1}
max={100}
defaultValue={filters.capacityMax ?? ""}
placeholder="Au plus"
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Budget max (/nuit)</span>
<input
type="number"
name="priceMax"
min={1}
step="10"
defaultValue={filters.priceMax ?? ""}
placeholder="ex. 100"
className="rounded-md border border-zinc-300 px-3 py-2 text-zinc-900 focus:border-emerald-500 focus:outline-none"
/>
</label>
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
<legend className="font-medium text-zinc-700">Accès route</legend>
<div className="flex flex-wrap gap-2 pt-1">
{[
{ value: RoadAccess.ALL_YEAR, label: "🛣️ Route toute saison" },
{ value: RoadAccess.DRY_SEASON_ONLY, label: "🟠 Route saison sèche" },
{ value: RoadAccess.NONE, label: "🛶 Pirogue uniquement" },
].map((opt) => {
const checked = (filters.roadAccess ?? []).includes(opt.value);
return (
<label
key={opt.value}
className={
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
(checked
? "border-emerald-600 bg-emerald-50 text-emerald-900"
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
}
>
<input
type="checkbox"
name="roadAccess"
value={opt.value}
defaultChecked={checked}
className="sr-only"
/>
{opt.label}
</label>
);
})}
</div>
</fieldset>
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
<legend className="font-medium text-zinc-700">Électricité</legend>
<div className="flex flex-wrap gap-2 pt-1">
{[
{ value: Electricity.EDF, label: "⚡ EDF / raccordé" },
{ value: Electricity.GENERATOR_READY, label: "🔌 Préinstall groupe" },
{ value: Electricity.SOLAR, label: "☀️ Solaire" },
{ value: Electricity.NONE, label: "🕯️ Aucune" },
].map((opt) => {
const checked = (filters.electricity ?? []).includes(opt.value);
return (
<label
key={opt.value}
className={
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
(checked
? "border-emerald-600 bg-emerald-50 text-emerald-900"
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
}
>
<input
type="checkbox"
name="electricity"
value={opt.value}
defaultChecked={checked}
className="sr-only"
/>
{opt.label}
</label>
);
})}
</div>
</fieldset>
<label className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
<span className="font-medium text-zinc-700">
📶 Réseau GSM accessible distance max{" "}
<span className="font-mono text-emerald-700">
{filters.gsmMaxKm === 0 ? "(au carbet)" : filters.gsmMaxKm ? `${filters.gsmMaxKm} km` : "(non filtré)"}
</span>
</span>
<div className="flex items-center gap-3">
<span className="text-xs text-zinc-500">Au carbet</span>
<input
type="range"
name="gsmMaxKm"
min={0}
max={10}
step={0.5}
defaultValue={filters.gsmMaxKm ?? ""}
className="flex-1 accent-emerald-600"
/>
<span className="text-xs text-zinc-500">10 km</span>
</div>
<span className="text-[11px] text-zinc-500">
0 km = exige le réseau directement au carbet · 10 km = peu importe.
</span>
</label>
<fieldset className="flex flex-col gap-1 text-sm sm:col-span-2 lg:col-span-5">
<legend className="font-medium text-zinc-700">Équipements souhaités</legend>
<div className="flex flex-wrap gap-2 pt-1">
{AMENITY_CATALOG.map((a) => {
const checked = (filters.amenities ?? []).includes(a.key);
return (
<label
key={a.key}
className={
"flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs " +
(checked
? "border-emerald-600 bg-emerald-50 text-emerald-900"
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
}
>
<input
type="checkbox"
name="amenities"
value={a.key}
defaultChecked={checked}
className="sr-only"
/>
{a.label}
</label>
);
})}
</div>
</fieldset>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5 lg:justify-end">
<Link
href="/carbets"

View file

@ -1,29 +0,0 @@
"use client";
import Link from "next/link";
import { SEARCH_PROFILES, buildProfileUrl } from "@/lib/search-profiles";
export function SearchProfiles() {
return (
<div className="mb-4">
<div className="mb-2 text-xs uppercase tracking-wider text-zinc-500">
Profils de séjour
</div>
<ul className="-mx-1 flex flex-wrap gap-1.5 px-1">
{SEARCH_PROFILES.map((p) => (
<li key={p.id}>
<Link
href={buildProfileUrl(p.id)}
title={p.description}
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-800 transition hover:border-emerald-400 hover:bg-emerald-50 hover:text-emerald-900"
>
<span aria-hidden>{p.emoji}</span>
<span className="font-medium">{p.label}</span>
</Link>
</li>
))}
</ul>
</div>
);
}

View file

@ -8,9 +8,7 @@ import {
} from "@/lib/carbet-search";
import { CarbetCard } from "./_components/carbet-card";
import { CatalogMap } from "./_components/catalog-map";
import { SearchFilters } from "./_components/search-filters";
import { SearchProfiles } from "./_components/search-profiles";
export const metadata: Metadata = {
title: "Rechercher un carbet",
@ -58,7 +56,6 @@ export default async function CarbetsSearchPage({
</p>
</header>
<SearchProfiles />
<SearchFilters filters={filters} rivers={rivers} />
<section className="mt-8" aria-live="polite">
@ -75,20 +72,6 @@ export default async function CarbetsSearchPage({
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
{results.length > 1 ? "s" : ""}.
</p>
<div className="mb-6">
<CatalogMap
points={results.map((c) => ({
id: c.id,
slug: c.slug,
title: c.title,
river: c.river,
nightlyPrice: c.nightlyPrice,
latitude: c.latitude,
longitude: c.longitude,
coverUrl: c.coverUrl,
}))}
/>
</div>
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{results.map((carbet) => (
<li key={carbet.id}>

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