From 55c024433643a59bcd6320bf232365c96a88a923 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Mon, 1 Jun 2026 16:20:06 +0000
Subject: [PATCH 01/34] fix: rebrancher espace-hote/page.tsx sur le nouveau
dashboard (oubli PR#59)
---
src/app/espace-hote/page.tsx | 292 +++++++++++++++++++++++++++++++++--
1 file changed, 277 insertions(+), 15 deletions(-)
diff --git a/src/app/espace-hote/page.tsx b/src/app/espace-hote/page.tsx
index d412d73..32f3d03 100644
--- a/src/app/espace-hote/page.tsx
+++ b/src/app/espace-hote/page.tsx
@@ -1,25 +1,287 @@
import Link from "next/link";
+import { auth } from "@/auth";
import { requireRole } from "@/lib/authorization";
+import { BookingStatus, UserRole } from "@/generated/prisma/enums";
+import {
+ getHostKpis,
+ listHostCarbets,
+ listHostRecentBookings,
+ isScopeAdmin,
+} from "@/lib/host-dashboard";
-export default async function HostPage() {
- const session = await requireRole(["OWNER", "ADMIN"]);
+import { BookingDecision } from "./_components/BookingDecision";
+
+export const dynamic = "force-dynamic";
+
+const STATUS_TONES: Record = {
+ PENDING: "bg-sky-100 text-sky-800 ring-sky-300",
+ CONFIRMED: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ CANCELLED: "bg-rose-100 text-rose-700 ring-rose-300",
+ COMPLETED: "bg-zinc-100 text-zinc-700 ring-zinc-300",
+ SUCCEEDED: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ REFUNDED: "bg-amber-100 text-amber-800 ring-amber-300",
+ FAILED: "bg-rose-100 text-rose-700 ring-rose-300",
+ AUTHORIZED: "bg-indigo-100 text-indigo-800 ring-indigo-300",
+ DRAFT: "bg-zinc-100 text-zinc-700 ring-zinc-300",
+ PUBLISHED: "bg-emerald-100 text-emerald-800 ring-emerald-300",
+ ARCHIVED: "bg-amber-100 text-amber-800 ring-amber-300",
+};
+
+const STATUS_LABEL: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ CANCELLED: "Annulée",
+ COMPLETED: "Terminée",
+ SUCCEEDED: "Payé",
+ REFUNDED: "Remboursé",
+ FAILED: "Échec",
+ AUTHORIZED: "Autorisé",
+ DRAFT: "Brouillon",
+ PUBLISHED: "Publié",
+ ARCHIVED: "Archivé",
+};
+
+function Badge({ value }: { value: string }) {
+ const tone = STATUS_TONES[value] ?? STATUS_TONES.PENDING;
+ return (
+
+ {STATUS_LABEL[value] ?? value}
+
+ );
+}
+
+function fmtEur(amount: string, currency: string): string {
+ const n = Number(amount);
+ return n.toLocaleString("fr-FR", { style: "currency", currency: currency || "EUR" });
+}
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "2-digit",
+});
+
+export default async function HostDashboardPage() {
+ await requireRole([UserRole.OWNER, UserRole.ADMIN]);
+ const session = await auth();
+ const userId = session!.user.id;
+ const isAdmin = isScopeAdmin(session?.user?.role);
+ const scope = { ownerId: userId, isAdmin };
+
+ const [kpis, recent, carbets] = await Promise.all([
+ getHostKpis(scope),
+ listHostRecentBookings(scope, 12),
+ listHostCarbets(scope),
+ ]);
+
+ const pendingBookings = recent.filter((b) => b.status === BookingStatus.PENDING);
return (
-
- Espace hôte
-
- Accès autorisé pour {session.user.email} ({session.user.role}).
-
+
+
-
-
- Gérer mes carbets
-
-
+
+
+
+
+ 0 ? "warn" : "neutral"}
+ />
+
+
+
+
+ {kpis.nextArrival ? (
+
+ Prochaine arrivée
+
+ {kpis.nextArrival.tenantName} · {kpis.nextArrival.carbetTitle}
+
+
+ {dateFmt.format(kpis.nextArrival.startDate)}
+
+
+ ) : null}
+
+ {pendingBookings.length > 0 ? (
+
+
+ Demandes en attente ({pendingBookings.length})
+
+
+ {pendingBookings.map((b) => (
+
+
+
+ {b.tenantName} — {b.carbetTitle}
+
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} ·{" "}
+ {b.guestCount} pers · {fmtEur(b.amount, b.currency)}
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+ Mes carbets ({carbets.length})
+
+ {carbets.length === 0 ? (
+
+ Aucun carbet pour l'instant.{" "}
+
+ Créer mon premier carbet
+
+
+ ) : (
+
+
+
+
+ Titre
+ Fleuve
+ €/nuit
+ Cap.
+ Médias
+ Résas
+ Avis
+ Statut
+
+
+
+ {carbets.map((c) => (
+
+
+
+ {c.title}
+
+
+ /{c.slug}
+
+
+ {c.river}
+
+ {Number(c.nightlyPrice).toFixed(0)}
+
+ {c.capacity}
+
+ {c._count.media}
+
+
+ {c._count.bookings}
+
+
+ {c._count.reviews}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {recent.length > 0 ? (
+
+
+ Activité récente
+
+
+
+
+
+ Carbet
+ Locataire
+ Séjour
+ Montant
+ Résa
+ Paiement
+
+
+
+ {recent.map((b) => (
+
+ {b.carbetTitle}
+ {b.tenantName}
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
+
+
+ {fmtEur(b.amount, b.currency)}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ ) : null}
);
}
+
+function Kpi({
+ label,
+ value,
+ tone = "neutral",
+}: {
+ label: string;
+ value: string;
+ tone?: "neutral" | "warn";
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
From a373bd60ad8bb3405316613f300f2d058e2e207f Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Mon, 1 Jun 2026 20:16:57 +0000
Subject: [PATCH 02/34] =?UTF-8?q?feat(hardening):=20rate=20limit=20(signup?=
=?UTF-8?q?/reset/bookings)=20+=20t=C3=A2ches=20cron=20+=20backup=20Postgr?=
=?UTF-8?q?eSQL=20nocturne?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/backup-postgres.sh | 51 ++++++++++++
src/app/api/bookings/route.ts | 9 +++
src/app/api/cron/run/[task]/route.ts | 37 +++++++++
src/app/api/password/reset-request/route.ts | 8 ++
src/app/api/signup/route.ts | 9 +++
src/lib/rate-limit.ts | 86 +++++++++++++++++++++
src/lib/scheduled.ts | 75 ++++++++++++++++++
tests/lib/rate-limit.test.ts | 44 +++++++++++
8 files changed, 319 insertions(+)
create mode 100755 scripts/backup-postgres.sh
create mode 100644 src/app/api/cron/run/[task]/route.ts
create mode 100644 src/lib/rate-limit.ts
create mode 100644 src/lib/scheduled.ts
create mode 100644 tests/lib/rate-limit.test.ts
diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh
new file mode 100755
index 0000000..fa2d461
--- /dev/null
+++ b/scripts/backup-postgres.sh
@@ -0,0 +1,51 @@
+#!/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 \
+ -v "$DUMP_DIR:/dump" \
+ minio/mc:latest sh -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}
+ " \
+ -e MINIO_ROOT_USER \
+ -e MINIO_ROOT_PASSWORD
+
+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 minio/mc:latest sh -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
+" \
+ -e MINIO_ROOT_USER \
+ -e MINIO_ROOT_PASSWORD
+
+echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)"
diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts
index 8ada7f7..e315ed3 100644
--- a/src/app/api/bookings/route.ts
+++ b/src/app/api/bookings/route.ts
@@ -17,6 +17,7 @@ import {
} from "@/lib/booking";
import { prisma } from "@/lib/prisma";
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
+import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
@@ -28,6 +29,14 @@ type CreateBookingBody = {
};
export async function POST(request: Request) {
+ const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
+ );
+ }
+
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
diff --git a/src/app/api/cron/run/[task]/route.ts b/src/app/api/cron/run/[task]/route.ts
new file mode 100644
index 0000000..ff2beba
--- /dev/null
+++ b/src/app/api/cron/run/[task]/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+
+import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function authorized(req: Request): boolean {
+ const secret = (process.env.CRON_TOKEN ?? "").trim();
+ if (!secret) return false;
+ const header = req.headers.get("authorization") ?? "";
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
+ return token === secret;
+}
+
+export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
+ if (!authorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const { task } = await ctx.params;
+ const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
+ if (!fn) {
+ return NextResponse.json(
+ { error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
+ { status: 404 },
+ );
+ }
+ try {
+ const result = await fn();
+ return NextResponse.json({ ok: true, task, result });
+ } catch (e) {
+ return NextResponse.json(
+ { error: e instanceof Error ? e.message : String(e) },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/password/reset-request/route.ts b/src/app/api/password/reset-request/route.ts
index 1eaedc9..9bcbc32 100644
--- a/src/app/api/password/reset-request/route.ts
+++ b/src/app/api/password/reset-request/route.ts
@@ -5,6 +5,7 @@ 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";
@@ -15,6 +16,13 @@ const schema = z.object({
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();
diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts
index b1044b8..1ded993 100644
--- a/src/app/api/signup/route.ts
+++ b/src/app/api/signup/route.ts
@@ -6,6 +6,7 @@ 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";
@@ -19,6 +20,14 @@ const schema = z.object({
});
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();
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
new file mode 100644
index 0000000..41c27f1
--- /dev/null
+++ b/src/lib/rate-limit.ts
@@ -0,0 +1,86 @@
+/**
+ * Token-bucket en mémoire — best-effort par instance.
+ *
+ * Pour un déploiement multi-instance, swap pour un store partagé (Redis).
+ * Ici on tourne en mono-instance Next derrière nginx-proxy-manager, donc
+ * une Map locale suffit.
+ *
+ * Usage :
+ * const r = await rateLimit({ key: ip + ":signup", windowMs: 60_000, limit: 5 });
+ * if (!r.ok) return tooManyRequests(r.retryAfter);
+ */
+
+type Bucket = {
+ count: number;
+ resetAt: number;
+};
+
+const buckets = new Map();
+
+const SWEEP_INTERVAL_MS = 60_000;
+let lastSweep = 0;
+
+function sweep(now: number) {
+ if (now - lastSweep < SWEEP_INTERVAL_MS) return;
+ lastSweep = now;
+ for (const [k, b] of buckets) {
+ if (b.resetAt <= now) buckets.delete(k);
+ }
+}
+
+export type RateLimitOpts = {
+ key: string;
+ /** Fenêtre glissante en ms. */
+ windowMs: number;
+ /** Nombre max d'appels par fenêtre. */
+ limit: number;
+};
+
+export type RateLimitResult = {
+ ok: boolean;
+ remaining: number;
+ retryAfter: number; // secondes
+};
+
+export function rateLimit(opts: RateLimitOpts): RateLimitResult {
+ const now = Date.now();
+ sweep(now);
+
+ const b = buckets.get(opts.key);
+ if (!b || b.resetAt <= now) {
+ buckets.set(opts.key, { count: 1, resetAt: now + opts.windowMs });
+ return { ok: true, remaining: opts.limit - 1, retryAfter: 0 };
+ }
+
+ if (b.count >= opts.limit) {
+ return {
+ ok: false,
+ remaining: 0,
+ retryAfter: Math.max(1, Math.ceil((b.resetAt - now) / 1000)),
+ };
+ }
+
+ b.count++;
+ return { ok: true, remaining: opts.limit - b.count, retryAfter: 0 };
+}
+
+/** Extract a client IP from a request, fallback to a safe default. */
+export function getClientIp(req: Request): string {
+ // nginx-proxy-manager pose x-forwarded-for, x-real-ip
+ const xff = req.headers.get("x-forwarded-for");
+ if (xff) return xff.split(",")[0].trim();
+ const xri = req.headers.get("x-real-ip");
+ if (xri) return xri.trim();
+ return "unknown";
+}
+
+/** Helper pratique : extract IP + applique le bucket. */
+export function rateLimitRequest(
+ req: Request,
+ bucket: string,
+ windowMs: number,
+ limit: number,
+): RateLimitResult {
+ const ip = getClientIp(req);
+ return rateLimit({ key: `${ip}:${bucket}`, windowMs, limit });
+}
diff --git a/src/lib/scheduled.ts b/src/lib/scheduled.ts
new file mode 100644
index 0000000..f9faee8
--- /dev/null
+++ b/src/lib/scheduled.ts
@@ -0,0 +1,75 @@
+/**
+ * Tâches planifiées exécutables via /api/cron/run/[task] avec le secret
+ * CRON_TOKEN. Idempotents, retournent un compteur d'actions.
+ */
+
+import "server-only";
+
+import { BookingStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+import { purgeExpiredResetTokens } from "@/lib/password-reset";
+
+const PENDING_TTL_DAYS = 7;
+
+/** Annule les bookings PENDING créés il y a plus de N jours. */
+export async function autoCancelStalePending(): Promise<{ cancelled: number }> {
+ const cutoff = new Date(Date.now() - PENDING_TTL_DAYS * 86_400_000);
+ const stale = await prisma.booking.findMany({
+ where: { status: BookingStatus.PENDING, createdAt: { lt: cutoff } },
+ select: { id: true },
+ });
+ if (stale.length === 0) return { cancelled: 0 };
+ await prisma.booking.updateMany({
+ where: { id: { in: stale.map((s) => s.id) } },
+ data: { status: BookingStatus.CANCELLED },
+ });
+ await recordAudit({
+ scope: "cron",
+ event: "bookings.auto-cancel-stale",
+ actorEmail: null,
+ details: { count: stale.length, cutoff: cutoff.toISOString() },
+ });
+ return { cancelled: stale.length };
+}
+
+/** Purge les password reset tokens expirés. */
+export async function purgeResetTokens(): Promise<{ purged: number }> {
+ const count = await purgeExpiredResetTokens();
+ if (count > 0) {
+ await recordAudit({
+ scope: "cron",
+ event: "password.purge-expired-tokens",
+ actorEmail: null,
+ details: { count },
+ });
+ }
+ return { purged: count };
+}
+
+/** Logique simple : retourne juste la liste des bookings dont l'arrivée est dans 3 jours.
+ * L'envoi email réel est branché plus tard quand RESEND_API_KEY est posée. */
+export async function listUpcomingArrivalsInThreeDays() {
+ const now = new Date();
+ const in3 = new Date(now.getTime() + 3 * 86_400_000);
+ const in4 = new Date(now.getTime() + 4 * 86_400_000);
+ return prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ startDate: { gte: in3, lt: in4 },
+ },
+ select: {
+ id: true,
+ startDate: true,
+ tenant: { select: { email: true, firstName: true } },
+ carbet: { select: { title: true, slug: true } },
+ },
+ });
+}
+
+export const SCHEDULED_TASKS = {
+ "auto-cancel-stale-pending": autoCancelStalePending,
+ "purge-reset-tokens": purgeResetTokens,
+} as const;
+
+export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS;
diff --git a/tests/lib/rate-limit.test.ts b/tests/lib/rate-limit.test.ts
new file mode 100644
index 0000000..4b97e3e
--- /dev/null
+++ b/tests/lib/rate-limit.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from "vitest";
+
+import { rateLimit } from "@/lib/rate-limit";
+
+describe("rateLimit", () => {
+ it("allows up to limit calls in window", () => {
+ const key = "test:" + Math.random();
+ for (let i = 0; i < 5; i++) {
+ const r = rateLimit({ key, windowMs: 60_000, limit: 5 });
+ expect(r.ok).toBe(true);
+ }
+ });
+
+ it("blocks the (limit+1)th call with retryAfter > 0", () => {
+ const key = "test:" + Math.random();
+ for (let i = 0; i < 3; i++) {
+ rateLimit({ key, windowMs: 60_000, limit: 3 });
+ }
+ const r = rateLimit({ key, windowMs: 60_000, limit: 3 });
+ expect(r.ok).toBe(false);
+ expect(r.retryAfter).toBeGreaterThan(0);
+ expect(r.remaining).toBe(0);
+ });
+
+ it("isolates different keys", () => {
+ const k1 = "test:" + Math.random();
+ const k2 = "test:" + Math.random();
+ for (let i = 0; i < 5; i++) {
+ rateLimit({ key: k1, windowMs: 60_000, limit: 5 });
+ }
+ const r = rateLimit({ key: k2, windowMs: 60_000, limit: 5 });
+ expect(r.ok).toBe(true);
+ });
+
+ it("resets after window expires", async () => {
+ const key = "test:" + Math.random();
+ rateLimit({ key, windowMs: 10, limit: 1 });
+ const blocked = rateLimit({ key, windowMs: 10, limit: 1 });
+ expect(blocked.ok).toBe(false);
+ await new Promise((r) => setTimeout(r, 15));
+ const after = rateLimit({ key, windowMs: 10, limit: 1 });
+ expect(after.ok).toBe(true);
+ });
+});
From 92deffa109766137128ae68660e229be60bae78b Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Mon, 1 Jun 2026 20:21:40 +0000
Subject: [PATCH 03/34] fix(backup): minio/mc a entrypoint=mc, ajouter
--entrypoint /bin/sh pour wrapper
---
scripts/backup-postgres.sh | 21 ++++++++++++---------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh
index fa2d461..abe63d4 100755
--- a/scripts/backup-postgres.sh
+++ b/scripts/backup-postgres.sh
@@ -26,14 +26,15 @@ 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" \
- minio/mc:latest sh -c "
+ -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}
- " \
- -e MINIO_ROOT_USER \
- -e MINIO_ROOT_PASSWORD
+ "
echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
@@ -41,11 +42,13 @@ echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
rm -f "$DUMP_FILE"
# Rétention : supprime les backups > 30 jours dans MinIO
-docker run --rm --network karbe-net minio/mc:latest sh -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
-" \
+docker run --rm --network karbe-net \
+ --entrypoint /bin/sh \
-e MINIO_ROOT_USER \
- -e MINIO_ROOT_PASSWORD
+ -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)"
From 71dd8c1dad19934ede439e39dc8ce635b088d6ae Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Mon, 1 Jun 2026 23:27:57 +0000
Subject: [PATCH 04/34] =?UTF-8?q?feat:=20carte=20interactive=20du=20catalo?=
=?UTF-8?q?gue=20+=20refonte=20page=20=C3=80=20propos=20(2.2-2.6k=20caract?=
=?UTF-8?q?=C3=A8res)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../carbets/_components/catalog-map-inner.tsx | 113 ++++++++++++++++++
src/app/carbets/_components/catalog-map.tsx | 29 +++++
src/app/carbets/page.tsx | 15 +++
src/lib/carbet-search.ts | 9 ++
4 files changed, 166 insertions(+)
create mode 100644 src/app/carbets/_components/catalog-map-inner.tsx
create mode 100644 src/app/carbets/_components/catalog-map.tsx
diff --git a/src/app/carbets/_components/catalog-map-inner.tsx b/src/app/carbets/_components/catalog-map-inner.tsx
new file mode 100644
index 0000000..1abac02
--- /dev/null
+++ b/src/app/carbets/_components/catalog-map-inner.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useMemo } from "react";
+import Link from "next/link";
+import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
+import L, { LatLngBoundsExpression } from "leaflet";
+
+import "leaflet/dist/leaflet.css";
+
+import type { CatalogMapPoint } from "./catalog-map";
+
+const ICON = L.divIcon({
+ className: "karbe-catalog-marker",
+ html: `
+
+ `,
+ iconSize: [28, 36],
+ iconAnchor: [14, 36],
+ popupAnchor: [0, -32],
+});
+
+export function CatalogMapInner({ points }: { points: CatalogMapPoint[] }) {
+ const bounds = useMemo(() => {
+ 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 (
+
+
+
+ {points.map((p) => (
+
+
+
+ {p.coverUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
{p.title}
+
+
+ Fleuve {p.river}
+
+
+
+ {Number(p.nightlyPrice).toFixed(0)} €
+
+
/ nuit
+
+
+ Voir la fiche →
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/carbets/_components/catalog-map.tsx b/src/app/carbets/_components/catalog-map.tsx
new file mode 100644
index 0000000..5f65463
--- /dev/null
+++ b/src/app/carbets/_components/catalog-map.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+const CatalogMapInner = dynamic(
+ () => import("./catalog-map-inner").then((m) => m.CatalogMapInner),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
+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 ;
+}
diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx
index a49ed1b..b700fed 100644
--- a/src/app/carbets/page.tsx
+++ b/src/app/carbets/page.tsx
@@ -8,6 +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";
export const metadata: Metadata = {
@@ -72,6 +73,20 @@ export default async function CarbetsSearchPage({
{results.length} carbet{results.length > 1 ? "s" : ""} trouvé
{results.length > 1 ? "s" : ""}.
+
+ ({
+ id: c.id,
+ slug: c.slug,
+ title: c.title,
+ river: c.river,
+ nightlyPrice: c.nightlyPrice,
+ latitude: c.latitude,
+ longitude: c.longitude,
+ coverUrl: c.coverUrl,
+ }))}
+ />
+
{results.map((carbet) => (
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index bed9fd5..b2cb041 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -110,6 +110,9 @@ export type CarbetSearchResult = {
mediaCount: number;
reviewCount: number;
averageRating: number | null;
+ nightlyPrice: string;
+ latitude: number;
+ longitude: number;
};
// Build the Prisma where-clause for a public carbet search. A carbet is only
@@ -179,6 +182,9 @@ export async function searchCarbets(
maxStayNights: true,
minCapacity: true,
description: true,
+ nightlyPrice: true,
+ latitude: true,
+ longitude: true,
media: {
orderBy: { sortOrder: "asc" },
take: 1,
@@ -213,6 +219,9 @@ export async function searchCarbets(
mediaCount: carbet._count.media,
reviewCount: stats.count,
averageRating: stats.averageRating,
+ nightlyPrice: carbet.nightlyPrice.toString(),
+ latitude: Number(carbet.latitude),
+ longitude: Number(carbet.longitude),
};
});
}
From 2914e5605ab55e42cca5e6f0cec7d7f8a9d3aa88 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Mon, 1 Jun 2026 23:35:30 +0000
Subject: [PATCH 05/34] =?UTF-8?q?feat:=20BookingForm=20bascule=20sur=20Str?=
=?UTF-8?q?ipe=20Checkout=20quand=20STRIPE=5FSECRET=5FKEY=20est=20pos?=
=?UTF-8?q?=C3=A9e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/carbets/[slug]/page.tsx | 3 ++
src/app/carbets/_components/booking-form.tsx | 42 +++++++++++++++++++-
src/lib/stripe.ts | 8 ++++
3 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
index 53544fd..f37adae 100644
--- a/src/app/carbets/[slug]/page.tsx
+++ b/src/app/carbets/[slug]/page.tsx
@@ -12,6 +12,8 @@ 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";
@@ -255,6 +257,7 @@ export default async function PublicCarbetPage({ params }: PageProps) {
minStayNights={carbet.minStayNights}
maxStayNights={carbet.maxStayNights}
isAuthenticated={Boolean(viewerId)}
+ stripeEnabled={isStripeConfigured()}
/>
diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx
index 522a017..816368c 100644
--- a/src/app/carbets/_components/booking-form.tsx
+++ b/src/app/carbets/_components/booking-form.tsx
@@ -14,6 +14,7 @@ type Props = {
minStayNights: number | null;
maxStayNights: number | null;
isAuthenticated: boolean;
+ stripeEnabled: boolean;
};
function todayPlus(n: number): string {
@@ -38,6 +39,7 @@ export function BookingForm({
minStayNights,
maxStayNights,
isAuthenticated,
+ stripeEnabled,
}: Props) {
const router = useRouter();
const [startDate, setStartDate] = useState(null);
@@ -88,6 +90,34 @@ export function BookingForm({
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" },
@@ -187,7 +217,13 @@ export function BookingForm({
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 ? "Réserver" : "Se connecter pour réserver"}
+ {busy
+ ? "Envoi…"
+ : !isAuthenticated
+ ? "Se connecter pour réserver"
+ : stripeEnabled
+ ? "Payer et réserver"
+ : "Réserver"}
{!isAuthenticated ? (
@@ -200,7 +236,9 @@ export function BookingForm({
) : null}
- Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation du paiement.
+ {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."}
);
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
index adda277..e0d1ca0 100644
--- a/src/lib/stripe.ts
+++ b/src/lib/stripe.ts
@@ -1,5 +1,13 @@
import Stripe from "stripe";
+/** Détecte si Stripe est utilisable (clé posée + pas un placeholder). */
+export function isStripeConfigured(): boolean {
+ const key = (process.env.STRIPE_SECRET_KEY ?? "").trim();
+ if (!key) return false;
+ if (key.includes("REPLACE_ME") || key.includes("PLACEHOLDER")) return false;
+ return key.startsWith("sk_test_") || key.startsWith("sk_live_") || key.startsWith("rk_");
+}
+
let stripeClient: Stripe | null = null;
export function getStripeClient(): Stripe {
From 2545a5e1a8532b16d780b1904d9ddc651359e9b0 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 00:27:16 +0000
Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=C2=AB=20Au=20fil=20de=20l'eau=20?=
=?UTF-8?q?=C2=BB=20=E2=80=94=20Reels=20mobile=20+=20uploader=20pro=20+=20?=
=?UTF-8?q?favoris?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 74 ++++
package.json | 4 +
.../20260602100000_favorite/migration.sql | 8 +
prisma/schema.prisma | 10 +
src/app/accueil/page.tsx | 60 +++
src/app/api/favorites/route.ts | 61 +++
src/app/api/media/[id]/route.ts | 41 ++
src/app/api/media/reorder/route.ts | 55 +++
src/app/api/uploads/finalize/route.ts | 66 +++
src/app/api/uploads/presign/route.ts | 55 +++
src/app/decouvrir/_components/ReelSlide.tsx | 256 ++++++++++++
src/app/decouvrir/_components/ReelsViewer.tsx | 139 +++++++
src/app/decouvrir/page.tsx | 50 +++
.../espace-hote/carbets/[carbetId]/page.tsx | 15 +-
src/app/mes-favoris/page.tsx | 63 +++
src/app/page.tsx | 62 +--
src/components/MediaUploader.tsx | 380 ++++++++++++++++++
src/components/SiteHeader.tsx | 11 +-
src/lib/reels.ts | 127 ++++++
src/lib/uploads.ts | 104 +++++
20 files changed, 1569 insertions(+), 72 deletions(-)
create mode 100644 prisma/migrations/20260602100000_favorite/migration.sql
create mode 100644 src/app/accueil/page.tsx
create mode 100644 src/app/api/favorites/route.ts
create mode 100644 src/app/api/media/[id]/route.ts
create mode 100644 src/app/api/media/reorder/route.ts
create mode 100644 src/app/api/uploads/finalize/route.ts
create mode 100644 src/app/api/uploads/presign/route.ts
create mode 100644 src/app/decouvrir/_components/ReelSlide.tsx
create mode 100644 src/app/decouvrir/_components/ReelsViewer.tsx
create mode 100644 src/app/decouvrir/page.tsx
create mode 100644 src/app/mes-favoris/page.tsx
create mode 100644 src/components/MediaUploader.tsx
create mode 100644 src/lib/reels.ts
create mode 100644 src/lib/uploads.ts
diff --git a/package-lock.json b/package-lock.json
index c1f89be..9dcbdb0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,10 @@
"hasInstallScript": true,
"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",
@@ -509,6 +513,23 @@
"node": ">=20.0.0"
}
},
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.1058.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz",
+ "integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.974.15",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.30",
+ "@aws-sdk/types": "^3.973.9",
+ "@smithy/core": "^3.24.5",
+ "@smithy/types": "^4.14.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.30",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
@@ -839,6 +860,59 @@
"node": ">=18"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
+ "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.1.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@electric-sql/pglite": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
diff --git a/package.json b/package.json
index 000a852..5bb9e15 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,10 @@
},
"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",
diff --git a/prisma/migrations/20260602100000_favorite/migration.sql b/prisma/migrations/20260602100000_favorite/migration.sql
new file mode 100644
index 0000000..8abf012
--- /dev/null
+++ b/prisma/migrations/20260602100000_favorite/migration.sql
@@ -0,0 +1,8 @@
+CREATE TABLE "Favorite" (
+ "userId" TEXT NOT NULL,
+ "carbetId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId")
+);
+CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId");
+CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f59864e..83d75c2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -371,3 +371,13 @@ model PasswordResetToken {
@@index([userId])
@@index([expiresAt])
}
+
+model Favorite {
+ userId String
+ carbetId String
+ createdAt DateTime @default(now())
+
+ @@id([userId, carbetId])
+ @@index([userId])
+ @@index([carbetId])
+}
diff --git a/src/app/accueil/page.tsx b/src/app/accueil/page.tsx
new file mode 100644
index 0000000..513e1ac
--- /dev/null
+++ b/src/app/accueil/page.tsx
@@ -0,0 +1,60 @@
+import Link from "next/link";
+import { IfPluginEnabled } from "@/components/IfPluginEnabled";
+import { HeroSection } from "@/components/landing/HeroSection";
+import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
+import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
+import { CESection } from "@/components/landing/CESection";
+import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
+import { LandingFooter } from "@/components/landing/Footer";
+
+export const metadata = { title: "Accueil — Karbé" };
+
+/**
+ * Landing « marketing » historique (hero + sections + footer riche). Conservée
+ * à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
+ */
+export default function LandingPage() {
+ return (
+ <>
+
+
+
+ Karbé — carbets fluviaux de Guyane
+
+
+ La marketplace pour louer des carbets le long des fleuves de Guyane.
+
+
+
+ Au fil de l'eau
+
+
+ Catalogue
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts
new file mode 100644
index 0000000..14824d5
--- /dev/null
+++ b/src/app/api/favorites/route.ts
@@ -0,0 +1,61 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+});
+
+async function requireSelf() {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Unauth");
+ return session.user.id;
+}
+
+export async function GET() {
+ try {
+ const userId = await requireSelf();
+ const rows = await prisma.favorite.findMany({
+ where: { userId },
+ orderBy: { createdAt: "desc" },
+ select: { carbetId: true },
+ });
+ return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
+ } catch {
+ return NextResponse.json({ ids: [] });
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const userId = await requireSelf();
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ await prisma.favorite.upsert({
+ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
+ create: { userId, carbetId: parsed.data.carbetId },
+ update: {},
+ });
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+}
+
+export async function DELETE(req: Request) {
+ try {
+ const userId = await requireSelf();
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ await prisma.favorite
+ .delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
+ .catch(() => null);
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+}
diff --git a/src/app/api/media/[id]/route.ts b/src/app/api/media/[id]/route.ts
new file mode 100644
index 0000000..56bebef
--- /dev/null
+++ b/src/app/api/media/[id]/route.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+async function requireOwnership(mediaId: string) {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Non authentifié");
+ const m = await prisma.media.findUnique({
+ where: { id: mediaId },
+ select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
+ });
+ if (!m) throw new Error("Média introuvable");
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
+ return { session, media: m };
+}
+
+export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
+ const { id } = await ctx.params;
+ try {
+ const { session, media } = await requireOwnership(id);
+ await prisma.media.delete({ where: { id } });
+ await recordAudit({
+ scope: "uploads",
+ event: "media.delete",
+ target: id,
+ actorEmail: session.user.email ?? null,
+ details: { carbetId: media.carbetId },
+ });
+ return NextResponse.json({ ok: true });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
+ return NextResponse.json({ error: msg }, { status });
+ }
+}
diff --git a/src/app/api/media/reorder/route.ts b/src/app/api/media/reorder/route.ts
new file mode 100644
index 0000000..e463118
--- /dev/null
+++ b/src/app/api/media/reorder/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+ orderedIds: z.array(z.string()).min(1).max(50),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ }
+ const { carbetId, orderedIds } = parsed.data;
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: carbetId },
+ select: { ownerId: true },
+ });
+ if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isAdmin && carbet.ownerId !== session.user.id) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+ const existing = await prisma.media.findMany({
+ where: { carbetId, id: { in: orderedIds } },
+ select: { id: true },
+ });
+ if (existing.length !== orderedIds.length) {
+ return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
+ }
+ await prisma.$transaction(
+ orderedIds.map((id, idx) =>
+ prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
+ ),
+ );
+ await recordAudit({
+ scope: "uploads",
+ event: "media.reorder",
+ target: carbetId,
+ actorEmail: session.user.email ?? null,
+ details: { count: orderedIds.length },
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/uploads/finalize/route.ts b/src/app/api/uploads/finalize/route.ts
new file mode 100644
index 0000000..91fd2cd
--- /dev/null
+++ b/src/app/api/uploads/finalize/route.ts
@@ -0,0 +1,66 @@
+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";
+
+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 },
+ });
+ return NextResponse.json({ media });
+}
diff --git a/src/app/api/uploads/presign/route.ts b/src/app/api/uploads/presign/route.ts
new file mode 100644
index 0000000..cbf60c6
--- /dev/null
+++ b/src/app/api/uploads/presign/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+import { presignCarbetUpload } from "@/lib/uploads";
+import { rateLimitRequest } from "@/lib/rate-limit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ carbetId: z.string().min(1),
+ mime: z.string().min(3).max(100),
+ sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
+});
+
+export async function POST(req: Request) {
+ const rl = rateLimitRequest(req, "presign", 60_000, 60);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429 },
+ );
+ }
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
+ }
+
+ const carbet = await prisma.carbet.findUnique({
+ where: { id: parsed.data.carbetId },
+ select: { id: true, ownerId: true },
+ });
+ if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
+ const isOwner = carbet.ownerId === session.user.id;
+ const isAdmin = session.user.role === UserRole.ADMIN;
+ if (!isOwner && !isAdmin) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ const result = await presignCarbetUpload({
+ carbetId: carbet.id,
+ mime: parsed.data.mime,
+ sizeBytes: parsed.data.sizeBytes,
+ });
+ if ("error" in result) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+ return NextResponse.json(result);
+}
diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx
new file mode 100644
index 0000000..26e3809
--- /dev/null
+++ b/src/app/decouvrir/_components/ReelSlide.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+
+import type { ReelCarbet } from "@/lib/reels";
+
+type Props = {
+ carbet: ReelCarbet;
+ isActive: boolean;
+ shouldPreload: boolean;
+ isFavorite: boolean;
+ onToggleFavorite: () => void;
+};
+
+export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) {
+ const [mediaIndex, setMediaIndex] = useState(0);
+ const [muted, setMuted] = useState(true);
+ const touchStart = useRef<{ x: number; y: number } | null>(null);
+ const videoRef = useRef(null);
+
+ const current = carbet.media[mediaIndex];
+
+ const nextMedia = useCallback(() => {
+ setMediaIndex((i) => (i + 1) % carbet.media.length);
+ }, [carbet.media.length]);
+ const prevMedia = useCallback(() => {
+ setMediaIndex((i) => (i - 1 + carbet.media.length) % carbet.media.length);
+ }, [carbet.media.length]);
+
+ // Auto-play/pause vidéos quand slide active
+ useEffect(() => {
+ if (!videoRef.current) return;
+ if (isActive && current?.type === "VIDEO") {
+ videoRef.current.play().catch(() => {});
+ } else {
+ videoRef.current.pause();
+ }
+ }, [isActive, current?.type, mediaIndex]);
+
+ // Reset au changement de slide (différé pour éviter cascading renders)
+ useEffect(() => {
+ if (isActive) return;
+ queueMicrotask(() => setMediaIndex(0));
+ }, [isActive]);
+
+ // Navigation clavier ← →
+ useEffect(() => {
+ if (!isActive) return;
+ function onKey(e: KeyboardEvent) {
+ const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
+ if (tag === "input" || tag === "textarea") return;
+ if (e.key === "ArrowRight" || e.key === "l") {
+ e.preventDefault();
+ nextMedia();
+ } else if (e.key === "ArrowLeft" || e.key === "h") {
+ e.preventDefault();
+ prevMedia();
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [isActive, nextMedia, prevMedia]);
+
+ function onTouchStart(e: React.TouchEvent) {
+ const t = e.touches[0];
+ touchStart.current = { x: t.clientX, y: t.clientY };
+ }
+ function onTouchEnd(e: React.TouchEvent) {
+ if (!touchStart.current) return;
+ const t = e.changedTouches[0];
+ const dx = t.clientX - touchStart.current.x;
+ const dy = t.clientY - touchStart.current.y;
+ touchStart.current = null;
+ // Seuil horizontal > vertical pour considérer un swipe horizontal
+ if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.2) {
+ if (dx < 0) nextMedia();
+ else prevMedia();
+ }
+ }
+
+ const share = useCallback(async () => {
+ const url = `${window.location.origin}/carbets/${carbet.slug}`;
+ const title = carbet.title;
+ if (navigator.share) {
+ navigator.share({ title, url }).catch(() => {});
+ } else {
+ navigator.clipboard?.writeText(url).catch(() => {});
+ }
+ }, [carbet.slug, carbet.title]);
+
+ if (!current) return null;
+
+ return (
+
+ {/* Média */}
+
+ {current.type === "VIDEO" ? (
+
setMuted((m) => !m)}
+ />
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+ {/* Voile dégradé en bas pour lisibilité */}
+
+
+ {/* Indicateurs progression médias (sticks en haut) */}
+ {carbet.media.length > 1 ? (
+
+ {carbet.media.map((_, i) => (
+
+ ))}
+
+ ) : null}
+
+ {/* Zones tap horizontales (50/50) sur desktop */}
+
+
+
+ {/* Sidebar boutons droite (mobile) */}
+
+
+
+
+
+
+
+ Favori
+
+
+
+
+
+
+
+
+
+
+
+
+ Partager
+
+
+ {current.type === "VIDEO" ? (
+
setMuted((m) => !m)}
+ className="flex flex-col items-center text-white"
+ aria-label={muted ? "Activer le son" : "Couper le son"}
+ >
+
+ {muted ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+ ) : null}
+
+
+ {/* Bloc info bas + CTAs */}
+
+
+
{carbet.title}
+ {carbet.averageRating !== null ? (
+
+ ★ {carbet.averageRating.toFixed(1)} ({carbet.reviewCount})
+
+ ) : null}
+
+
+ 📍 {carbet.river}
+ ·
+ 👥 jusqu'à {carbet.capacity}
+ ·
+ {Number(carbet.nightlyPrice).toFixed(0)} € / nuit
+
+
+
+ Voir la fiche
+
+
+ Réserver
+
+
+
+
+ );
+}
diff --git a/src/app/decouvrir/_components/ReelsViewer.tsx b/src/app/decouvrir/_components/ReelsViewer.tsx
new file mode 100644
index 0000000..e7f925c
--- /dev/null
+++ b/src/app/decouvrir/_components/ReelsViewer.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+
+import type { ReelCarbet } from "@/lib/reels";
+
+import { ReelSlide } from "./ReelSlide";
+
+type Props = {
+ carbets: ReelCarbet[];
+ initialFavoriteIds: string[];
+ isAuthenticated: boolean;
+};
+
+export function ReelsViewer({ carbets, initialFavoriteIds, isAuthenticated }: Props) {
+ const router = useRouter();
+ const containerRef = useRef(null);
+ const slideRefs = useRef<(HTMLDivElement | null)[]>([]);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [favorites, setFavorites] = useState>(new Set(initialFavoriteIds));
+
+ // Détection du carbet actif via IntersectionObserver
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const visible = entries.filter((e) => e.isIntersecting);
+ if (visible.length === 0) return;
+ const best = visible.reduce((a, b) => (a.intersectionRatio > b.intersectionRatio ? a : b));
+ const idx = slideRefs.current.findIndex((el) => el === best.target);
+ if (idx !== -1) setActiveIndex(idx);
+ },
+ { root: containerRef.current, threshold: [0.55, 0.85] },
+ );
+ slideRefs.current.forEach((el) => el && observer.observe(el));
+ return () => observer.disconnect();
+ }, [carbets.length]);
+
+ // Navigation clavier ↑↓
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
+ if (tag === "input" || tag === "textarea") return;
+ if (e.key === "ArrowDown" || e.key === "j") {
+ e.preventDefault();
+ const next = Math.min(activeIndex + 1, carbets.length - 1);
+ slideRefs.current[next]?.scrollIntoView({ behavior: "smooth", block: "start" });
+ } else if (e.key === "ArrowUp" || e.key === "k") {
+ e.preventDefault();
+ const prev = Math.max(activeIndex - 1, 0);
+ slideRefs.current[prev]?.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [activeIndex, carbets.length]);
+
+ const toggleFavorite = useCallback(
+ async (carbetId: string) => {
+ if (!isAuthenticated) {
+ router.push(`/connexion?next=${encodeURIComponent("/decouvrir")}`);
+ return;
+ }
+ const isFav = favorites.has(carbetId);
+ // Optimistic update
+ setFavorites((prev) => {
+ const next = new Set(prev);
+ if (isFav) next.delete(carbetId);
+ else next.add(carbetId);
+ return next;
+ });
+ const method = isFav ? "DELETE" : "POST";
+ const res = await fetch("/api/favorites", {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ carbetId }),
+ });
+ if (!res.ok) {
+ // Rollback
+ setFavorites((prev) => {
+ const next = new Set(prev);
+ if (isFav) next.add(carbetId);
+ else next.delete(carbetId);
+ return next;
+ });
+ }
+ },
+ [favorites, isAuthenticated, router],
+ );
+
+ // Préchargement N+1 et N-1 médias (un peu d'AGGRESSIVE prefetch)
+ const preloadIndexes = useMemo(
+ () => [activeIndex - 1, activeIndex, activeIndex + 1].filter((i) => i >= 0 && i < carbets.length),
+ [activeIndex, carbets.length],
+ );
+
+ return (
+
+ {/* Bouton retour catalogue */}
+
+ ← Catalogue
+
+
+ {/* Compteur */}
+
+ {activeIndex + 1} / {carbets.length}
+
+
+
+ {carbets.map((c, idx) => (
+
{
+ slideRefs.current[idx] = el;
+ }}
+ className="h-full snap-start snap-always"
+ style={{ scrollSnapAlign: "start" }}
+ >
+ toggleFavorite(c.id)}
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/decouvrir/page.tsx b/src/app/decouvrir/page.tsx
new file mode 100644
index 0000000..ed232bf
--- /dev/null
+++ b/src/app/decouvrir/page.tsx
@@ -0,0 +1,50 @@
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { listReelCarbets } from "@/lib/reels";
+
+import { ReelsViewer } from "./_components/ReelsViewer";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = {
+ title: "Au fil de l'eau",
+ description: "Découvrez les carbets de Guyane façon Reels — swipez pour explorer.",
+};
+
+export default async function DecouvrirPage() {
+ const session = await auth();
+ const userId = session?.user?.id ?? null;
+ const [carbets, favoriteIds] = await Promise.all([
+ listReelCarbets({ take: 30 }),
+ userId
+ ? prisma.favorite.findMany({ where: { userId }, select: { carbetId: true } }).then((r) => r.map((x) => x.carbetId))
+ : Promise.resolve([] as string[]),
+ ]);
+
+ if (carbets.length === 0) {
+ return (
+
+ Au fil de l'eau
+
+ Pas encore assez de carbets avec des photos pour démarrer le mode immersif.
+
+
+ Voir le catalogue
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx
index 93768b1..2b8b069 100644
--- a/src/app/espace-hote/carbets/[carbetId]/page.tsx
+++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx
@@ -3,11 +3,10 @@ import { notFound } from "next/navigation";
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
import { prisma } from "@/lib/prisma";
-import { isStorageConfigured } from "@/lib/storage";
+import { MediaUploader } from "@/components/MediaUploader";
import { updateCarbet } from "../actions";
import { CarbetForm } from "../_components/carbet-form";
-import { MediaManager } from "../_components/media-manager";
export default async function EditCarbetPage({
params,
@@ -36,7 +35,7 @@ export default async function EditCarbetPage({
status: true,
media: {
orderBy: { sortOrder: "asc" },
- select: { id: true, type: true, s3Url: true, sortOrder: true },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
},
@@ -80,14 +79,10 @@ export default async function EditCarbetPage({
Médias
- Le premier média sert de photo de couverture. Réordonnez avec les
- flèches.
+ Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
+ Le premier média sert de cover sur le catalogue et la home.
-
+
diff --git a/src/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx
new file mode 100644
index 0000000..6ec4097
--- /dev/null
+++ b/src/app/mes-favoris/page.tsx
@@ -0,0 +1,63 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { listFavoriteCarbets } from "@/lib/reels";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = { title: "Mes favoris" };
+
+export default async function MyFavoritesPage() {
+ const session = await auth();
+ if (!session?.user?.id) redirect("/connexion?next=/mes-favoris");
+
+ const carbets = await listFavoriteCarbets(session.user.id);
+
+ return (
+
+ Mes favoris
+
+ {carbets.length === 0
+ ? "Aucun favori pour l'instant — ajoutez des carbets depuis le mode Au fil de l'eau ou les fiches."
+ : `${carbets.length} carbet${carbets.length > 1 ? "s" : ""} sauvegardé${carbets.length > 1 ? "s" : ""}.`}
+
+
+ {carbets.length === 0 ? (
+
+
+ Découvrir des carbets
+
+
+ ) : (
+
+ {carbets.map((c) => (
+
+
+ {c.media[0] ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
{c.title}
+
+ {c.river} · {Number(c.nightlyPrice).toFixed(0)} € / nuit
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 5d0099b..ad5f2bd 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,63 +1,9 @@
-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";
+import { redirect } from "next/navigation";
/**
- * Page d'accueil — la majorité du contenu est conditionnée par les plugins :
- * - `landing-hero` → hero plein écran
- * - `landing-sections` → 2 expériences + comment ça marche + CE + témoignages + footer riche
- *
- * Si aucun de ces plugins n'est activé, on retombe sur la home historique
- * minimaliste (fallback). Activable depuis /admin/plugins.
+ * Home redirige vers le mode immersif « Au fil de l'eau » par défaut.
+ * L'ancien hero/landing reste accessible via /accueil.
*/
export default function Home() {
- return (
- <>
-
-
-
- Karbé — carbets fluviaux de Guyane
-
-
- La marketplace pour louer des carbets le long des fleuves de Guyane.
-
-
-
- Découvrir les carbets
-
-
- Espace hôte
-
-
-
-
- }
- >
-
-
-
-
-
-
-
-
-
-
- >
- );
+ redirect("/decouvrir");
}
diff --git a/src/components/MediaUploader.tsx b/src/components/MediaUploader.tsx
new file mode 100644
index 0000000..815d991
--- /dev/null
+++ b/src/components/MediaUploader.tsx
@@ -0,0 +1,380 @@
+"use client";
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import {
+ DndContext,
+ PointerSensor,
+ TouchSensor,
+ KeyboardSensor,
+ closestCenter,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ arrayMove,
+ rectSortingStrategy,
+ useSortable,
+ sortableKeyboardCoordinates,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+
+export type MediaItem = {
+ id: string;
+ type: "PHOTO" | "VIDEO";
+ s3Url: string;
+ s3Key: string;
+ sortOrder: number;
+};
+
+type Props = {
+ carbetId: string;
+ initialMedia: MediaItem[];
+};
+
+type UploadEntry = {
+ tempId: string;
+ name: string;
+ sizeBytes: number;
+ mime: string;
+ progress: number;
+ error?: string;
+ done: boolean;
+};
+
+const MAX_PARALLEL = 3;
+
+export function MediaUploader({ carbetId, initialMedia }: Props) {
+ const [items, setItems] = useState(
+ [...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
+ );
+ const [uploads, setUploads] = useState([]);
+ const [dragging, setDragging] = useState(false);
+ const inputId = useId();
+ const fileInput = useRef(null);
+ const queueRef = useRef([]);
+ const activeRef = useRef(0);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
+ useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ const allIds = useMemo(() => items.map((i) => i.id), [items]);
+
+ const reorderOnServer = useCallback(
+ async (orderedIds: string[]) => {
+ await fetch("/api/media/reorder", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ carbetId, orderedIds }),
+ }).catch(() => {});
+ },
+ [carbetId],
+ );
+
+ function onDragEnd(e: DragEndEvent) {
+ const { active, over } = e;
+ if (!over || active.id === over.id) return;
+ setItems((prev) => {
+ const oldIdx = prev.findIndex((p) => p.id === active.id);
+ const newIdx = prev.findIndex((p) => p.id === over.id);
+ if (oldIdx < 0 || newIdx < 0) return prev;
+ const next = arrayMove(prev, oldIdx, newIdx);
+ reorderOnServer(next.map((m) => m.id));
+ return next;
+ });
+ }
+
+ const setCover = useCallback(
+ (id: string) => {
+ setItems((prev) => {
+ const idx = prev.findIndex((p) => p.id === id);
+ if (idx <= 0) return prev;
+ const next = arrayMove(prev, idx, 0);
+ reorderOnServer(next.map((m) => m.id));
+ return next;
+ });
+ },
+ [reorderOnServer],
+ );
+
+ const removeItem = useCallback(async (id: string) => {
+ if (!confirm("Supprimer ce média ?")) return;
+ const res = await fetch(`/api/media/${id}`, { method: "DELETE" });
+ if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
+ }, []);
+
+ const processFile = useCallback(async function processFile(file: File): Promise {
+ const tempId = crypto.randomUUID();
+ setUploads((u) => [
+ ...u,
+ { tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false },
+ ]);
+ try {
+ const presignRes = await fetch("/api/uploads/presign", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }),
+ });
+ const presign = await presignRes.json();
+ if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
+
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.upload.addEventListener("progress", (ev) => {
+ if (!ev.lengthComputable) return;
+ const pct = Math.round((ev.loaded / ev.total) * 100);
+ setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: pct } : x)));
+ });
+ xhr.addEventListener("load", () =>
+ xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)),
+ );
+ xhr.addEventListener("error", () => reject(new Error("Réseau coupé")));
+ xhr.open("PUT", presign.uploadUrl);
+ xhr.setRequestHeader("Content-Type", file.type);
+ xhr.send(file);
+ });
+
+ const finalizeRes = await fetch("/api/uploads/finalize", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ carbetId,
+ s3Key: presign.s3Key,
+ s3Url: presign.publicUrl,
+ mime: file.type,
+ }),
+ });
+ const finalize = await finalizeRes.json();
+ if (!finalizeRes.ok) throw new Error(finalize?.error || "finalize refusé");
+ setItems((prev) => [...prev, finalize.media]);
+ setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: 100, done: true } : x)));
+ // Cleanup après 2s
+ setTimeout(() => {
+ setUploads((u) => u.filter((x) => x.tempId !== tempId));
+ }, 2000);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x)));
+ }
+ }, [carbetId]);
+
+ const popQueueRef = useRef<() => void>(() => {});
+ const popQueue = useCallback(() => {
+ while (activeRef.current < MAX_PARALLEL && queueRef.current.length > 0) {
+ const file = queueRef.current.shift()!;
+ activeRef.current++;
+ processFile(file).finally(() => {
+ activeRef.current--;
+ popQueueRef.current();
+ });
+ }
+ }, [processFile]);
+ useEffect(() => {
+ popQueueRef.current = popQueue;
+ }, [popQueue]);
+
+ function addFiles(files: FileList | File[]) {
+ const arr = Array.from(files);
+ queueRef.current.push(...arr);
+ popQueue();
+ }
+
+ function onChange(e: React.ChangeEvent) {
+ if (e.target.files) addFiles(e.target.files);
+ if (fileInput.current) fileInput.current.value = "";
+ }
+
+ function onDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setDragging(false);
+ if (e.dataTransfer.files) addFiles(e.dataTransfer.files);
+ }
+
+ // Permet le coller depuis presse-papier
+ useEffect(() => {
+ function onPaste(e: ClipboardEvent) {
+ if (!e.clipboardData?.files?.length) return;
+ addFiles(e.clipboardData.files);
+ }
+ window.addEventListener("paste", onPaste);
+ return () => window.removeEventListener("paste", onPaste);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
{
+ e.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={onDrop}
+ className={
+ "rounded-lg border-2 border-dashed p-4 text-center transition " +
+ (dragging
+ ? "border-emerald-500 bg-emerald-50"
+ : "border-zinc-300 bg-zinc-50 hover:border-zinc-400")
+ }
+ >
+
+
+ Déposez vos photos ou vidéos ici, ou cliquez pour parcourir
+
+
+ JPG / PNG / WebP / AVIF (max 10 Mo) · MP4 / MOV / WebM (max 200 Mo) · plusieurs fichiers OK
+
+
+
+
+
+ {uploads.length > 0 ? (
+
+ ) : null}
+
+ {items.length > 0 ? (
+
+
+
+ {items.map((item, idx) => (
+ setCover(item.id)}
+ onDelete={() => removeItem(item.id)}
+ />
+ ))}
+
+
+
+ ) : (
+
+ Pas encore de média. Ajoutez votre premier ci-dessus.
+
+ )}
+
+
+ Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue)
+
+
+ );
+}
+
+function SortableTile({
+ item,
+ isCover,
+ onSetCover,
+ onDelete,
+}: {
+ item: MediaItem;
+ isCover: boolean;
+ onSetCover: () => void;
+ onDelete: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: item.id,
+ });
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+ return (
+
+
+ {item.type === "VIDEO" ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ {isCover ? (
+
+ Cover
+
+ ) : null}
+
+ {item.type}
+
+
+ {!isCover ? (
+
+ ★ Cover
+
+ ) : null}
+
+ ✕
+
+
+
+ );
+}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
index 96d9836..08ffe77 100644
--- a/src/components/SiteHeader.tsx
+++ b/src/components/SiteHeader.tsx
@@ -27,20 +27,23 @@ export async function SiteHeader() {
+
+ Au fil de l'eau
+
- Carbets
+ Catalogue
Comment ça marche
-
- Comités d'entreprise
-
{u ? (
<>
+
+ Favoris
+
Mes réservations
diff --git a/src/lib/reels.ts b/src/lib/reels.ts
new file mode 100644
index 0000000..6ac0033
--- /dev/null
+++ b/src/lib/reels.ts
@@ -0,0 +1,127 @@
+import "server-only";
+
+import { CarbetStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type ReelMedia = {
+ id: string;
+ type: "PHOTO" | "VIDEO";
+ url: string;
+};
+
+export type ReelCarbet = {
+ id: string;
+ slug: string;
+ title: string;
+ river: string;
+ embarkPoint: string;
+ capacity: number;
+ nightlyPrice: string;
+ ownerFirstName: string;
+ averageRating: number | null;
+ reviewCount: number;
+ media: ReelMedia[];
+};
+
+export async function listReelCarbets(opts: { take?: number } = {}): Promise
{
+ const take = opts.take ?? 30;
+ const rows = await prisma.carbet.findMany({
+ where: {
+ status: CarbetStatus.PUBLISHED,
+ media: { some: {} }, // au moins 1 média
+ },
+ orderBy: [{ lastBookedAt: { sort: "desc", nulls: "last" } }, { updatedAt: "desc" }],
+ take,
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ embarkPoint: true,
+ capacity: true,
+ nightlyPrice: true,
+ owner: { select: { firstName: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true },
+ },
+ reviews: { select: { rating: true } },
+ },
+ });
+
+ return rows.map((c) => {
+ const ratings = c.reviews.map((r) => r.rating);
+ const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length;
+ return {
+ id: c.id,
+ slug: c.slug,
+ title: c.title,
+ river: c.river,
+ embarkPoint: c.embarkPoint,
+ capacity: c.capacity,
+ nightlyPrice: c.nightlyPrice.toString(),
+ ownerFirstName: c.owner.firstName,
+ averageRating: avg,
+ reviewCount: ratings.length,
+ media: c.media.map((m) => ({
+ id: m.id,
+ type: m.type as "PHOTO" | "VIDEO",
+ url: m.s3Url,
+ })),
+ };
+ });
+}
+
+export async function listFavoriteCarbets(userId: string): Promise {
+ const favs = await prisma.favorite.findMany({
+ where: { userId },
+ select: { carbetId: true },
+ orderBy: { createdAt: "desc" },
+ });
+ if (favs.length === 0) return [];
+ const ids = favs.map((f) => f.carbetId);
+ const rows = await prisma.carbet.findMany({
+ where: { id: { in: ids }, status: CarbetStatus.PUBLISHED },
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ river: true,
+ embarkPoint: true,
+ capacity: true,
+ nightlyPrice: true,
+ owner: { select: { firstName: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true },
+ },
+ reviews: { select: { rating: true } },
+ },
+ });
+ // Respecter l'ordre des favoris (le plus récent en premier)
+ const byId = new Map(rows.map((r) => [r.id, r]));
+ return ids
+ .map((id) => byId.get(id))
+ .filter((r): r is NonNullable => Boolean(r))
+ .map((c) => {
+ const ratings = c.reviews.map((r) => r.rating);
+ const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length;
+ return {
+ id: c.id,
+ slug: c.slug,
+ title: c.title,
+ river: c.river,
+ embarkPoint: c.embarkPoint,
+ capacity: c.capacity,
+ nightlyPrice: c.nightlyPrice.toString(),
+ ownerFirstName: c.owner.firstName,
+ averageRating: avg,
+ reviewCount: ratings.length,
+ media: c.media.map((m) => ({
+ id: m.id,
+ type: m.type as "PHOTO" | "VIDEO",
+ url: m.s3Url,
+ })),
+ };
+ });
+}
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts
new file mode 100644
index 0000000..708504a
--- /dev/null
+++ b/src/lib/uploads.ts
@@ -0,0 +1,104 @@
+import "server-only";
+
+import crypto from "node:crypto";
+import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+
+const ENDPOINT = process.env.S3_ENDPOINT ?? "";
+const PUBLIC_BASE = process.env.S3_PUBLIC_URL ?? "";
+const BUCKET = process.env.S3_BUCKET ?? "";
+const REGION = process.env.S3_REGION ?? "us-east-1";
+const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "";
+const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "";
+
+const PUBLIC_BASE_EXTERNAL =
+ process.env.S3_PUBLIC_URL_EXTERNAL ?? PUBLIC_BASE;
+const ENDPOINT_EXTERNAL = process.env.S3_ENDPOINT_EXTERNAL ?? ENDPOINT;
+
+const s3Internal = new S3Client({
+ endpoint: ENDPOINT,
+ region: REGION,
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
+});
+
+const s3Presign = new S3Client({
+ endpoint: ENDPOINT_EXTERNAL,
+ region: REGION,
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
+});
+
+export type PresignResult = {
+ s3Key: string;
+ uploadUrl: string;
+ publicUrl: string;
+ expiresIn: number;
+};
+
+const ALLOWED_PHOTO_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/avif"]);
+const ALLOWED_VIDEO_MIMES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
+
+export type UploadKind = "photo" | "video";
+
+export function classifyMime(mime: string): UploadKind | null {
+ if (ALLOWED_PHOTO_MIMES.has(mime)) return "photo";
+ if (ALLOWED_VIDEO_MIMES.has(mime)) return "video";
+ return null;
+}
+
+const MAX_PHOTO = 10 * 1024 * 1024;
+const MAX_VIDEO = 200 * 1024 * 1024;
+
+export function maxBytesFor(kind: UploadKind): number {
+ return kind === "photo" ? MAX_PHOTO : MAX_VIDEO;
+}
+
+export function extensionFor(mime: string): string {
+ switch (mime) {
+ case "image/jpeg":
+ return "jpg";
+ case "image/png":
+ return "png";
+ case "image/webp":
+ return "webp";
+ case "image/avif":
+ return "avif";
+ case "video/mp4":
+ return "mp4";
+ case "video/quicktime":
+ return "mov";
+ case "video/webm":
+ return "webm";
+ default:
+ return "bin";
+ }
+}
+
+export async function presignCarbetUpload(opts: {
+ carbetId: string;
+ mime: string;
+ sizeBytes: number;
+}): Promise {
+ const kind = classifyMime(opts.mime);
+ if (!kind) return { error: `Type non supporté : ${opts.mime}` };
+ const max = maxBytesFor(kind);
+ if (opts.sizeBytes > max) {
+ return { error: `Fichier trop volumineux (${Math.round(opts.sizeBytes / 1_000_000)} Mo, max ${Math.round(max / 1_000_000)} Mo).` };
+ }
+ const id = crypto.randomBytes(12).toString("hex");
+ const ext = extensionFor(opts.mime);
+ const s3Key = `carbets/${opts.carbetId}/${Date.now()}-${id}.${ext}`;
+
+ const cmd = new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: s3Key,
+ ContentType: opts.mime,
+ });
+ const uploadUrl = await getSignedUrl(s3Presign, cmd, { expiresIn: 600 });
+ const publicUrl = `${PUBLIC_BASE_EXTERNAL.replace(/\/$/, "")}/${s3Key}`;
+ return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
+}
+
+export { s3Internal };
+export { BUCKET as UPLOAD_BUCKET };
From 701a1f02bd2576fa0081c2b2267cb74073ba4337 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 00:52:57 +0000
Subject: [PATCH 07/34] =?UTF-8?q?feat:=20Reels=20plein=20=C3=A9cran=20mobi?=
=?UTF-8?q?le=20+=20MediaUploader=20dans=20l'admin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/carbets/[id]/page.tsx | 27 +++++++++++--------
src/app/decouvrir/_components/ReelsViewer.tsx | 25 ++++++++++++++---
src/components/SiteHeaderGuard.tsx | 2 ++
3 files changed, 40 insertions(+), 14 deletions(-)
diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx
index 02c0d80..7799bef 100644
--- a/src/app/admin/carbets/[id]/page.tsx
+++ b/src/app/admin/carbets/[id]/page.tsx
@@ -7,7 +7,7 @@ import {
} from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
-import { MediaManager } from "./_components/MediaManager";
+import { MediaUploader } from "@/components/MediaUploader";
import { StatusActions } from "./_components/StatusActions";
import { updateCarbetAction } from "../actions";
@@ -61,16 +61,21 @@ export default async function EditCarbetPage({ params }: PageProps) {
- ({
- id: m.id,
- type: m.type,
- s3Key: m.s3Key,
- s3Url: m.s3Url,
- sortOrder: m.sortOrder,
- }))}
- />
+
+
+ Médias
+
+ ({
+ id: m.id,
+ type: m.type,
+ s3Key: m.s3Key,
+ s3Url: m.s3Url,
+ sortOrder: m.sortOrder,
+ }))}
+ />
+
+
{/* Bouton retour catalogue */}
← Catalogue
{/* Compteur */}
-
+
{activeIndex + 1} / {carbets.length}
+ {/* Logo Karbé en surimpression haut centre */}
+
+ Karbé
+
+
;
}
From e2d3b6a686094795d349e93026decec7d8806bea Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 01:05:25 +0000
Subject: [PATCH 08/34] feat: variantes responsives 320/800/1600 via sharp +
srcset partout (Reels, cards, galerie, favoris)
---
package-lock.json | 5 +-
package.json | 1 +
src/app/api/uploads/finalize/route.ts | 23 ++++
src/app/carbets/_components/carbet-card.tsx | 6 +-
.../carbets/_components/carbet-gallery.tsx | 12 ++
src/app/decouvrir/_components/ReelSlide.tsx | 5 +
src/app/mes-favoris/page.tsx | 5 +
src/components/ResponsiveImage.tsx | 56 ++++++++
src/lib/image-variants.ts | 41 ++++++
src/lib/variants-server.ts | 126 ++++++++++++++++++
tests/lib/image-variants.test.ts | 38 ++++++
11 files changed, 312 insertions(+), 6 deletions(-)
create mode 100644 src/components/ResponsiveImage.tsx
create mode 100644 src/lib/image-variants.ts
create mode 100644 src/lib/variants-server.ts
create mode 100644 tests/lib/image-variants.test.ts
diff --git a/package-lock.json b/package-lock.json
index 9dcbdb0..7d8475d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"resend": "^4.8.0",
+ "sharp": "^0.34.5",
"stripe": "^18.3.0"
},
"devDependencies": {
@@ -1646,7 +1647,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -5398,7 +5398,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -9574,7 +9573,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -9618,7 +9616,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/package.json b/package.json
index 5bb9e15..e0a10f1 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"resend": "^4.8.0",
+ "sharp": "^0.34.5",
"stripe": "^18.3.0"
},
"devDependencies": {
diff --git a/src/app/api/uploads/finalize/route.ts b/src/app/api/uploads/finalize/route.ts
index 91fd2cd..c9f7cd8 100644
--- a/src/app/api/uploads/finalize/route.ts
+++ b/src/app/api/uploads/finalize/route.ts
@@ -6,6 +6,7 @@ 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";
@@ -62,5 +63,27 @@ export async function POST(req: Request) {
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 });
}
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
index 9a6a53b..c11003a 100644
--- a/src/app/carbets/_components/carbet-card.tsx
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import type { CarbetSearchResult } from "@/lib/carbet-search";
import { formatPirogueDuration, truncate } from "@/lib/format";
import { formatAverageRating } from "@/lib/reviews";
+import { buildSrcSet } from "@/lib/image-variants";
import { AccessTypeBadge } from "@/components/AccessTypeBadge";
import { StayConstraints } from "@/components/StayConstraints";
@@ -14,13 +15,14 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
{carbet.coverUrl ? (
- // Use a plain 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
) : (
diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx
index a5c7ca1..4122a35 100644
--- a/src/app/carbets/_components/carbet-gallery.tsx
+++ b/src/app/carbets/_components/carbet-gallery.tsx
@@ -4,6 +4,7 @@ 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;
@@ -73,7 +74,11 @@ export function CarbetGallery({ title, media }: Props) {
// eslint-disable-next-line @next/next/no-img-element
)}
@@ -101,8 +106,11 @@ export function CarbetGallery({ title, media }: Props) {
// eslint-disable-next-line @next/next/no-img-element
)}
@@ -179,7 +187,11 @@ export function CarbetGallery({ title, media }: Props) {
// eslint-disable-next-line @next/next/no-img-element
)}
diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx
index 26e3809..a8476f4 100644
--- a/src/app/decouvrir/_components/ReelSlide.tsx
+++ b/src/app/decouvrir/_components/ReelSlide.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import type { ReelCarbet } from "@/lib/reels";
+import { buildSrcSet } from "@/lib/image-variants";
type Props = {
carbet: ReelCarbet;
@@ -115,9 +116,13 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl
// eslint-disable-next-line @next/next/no-img-element
)}
diff --git a/src/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx
index 6ec4097..5887400 100644
--- a/src/app/mes-favoris/page.tsx
+++ b/src/app/mes-favoris/page.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import { auth } from "@/auth";
import { listFavoriteCarbets } from "@/lib/reels";
+import { buildSrcSet } from "@/lib/image-variants";
export const dynamic = "force-dynamic";
@@ -41,7 +42,11 @@ export default async function MyFavoritesPage() {
// eslint-disable-next-line @next/next/no-img-element
) : (
diff --git a/src/components/ResponsiveImage.tsx b/src/components/ResponsiveImage.tsx
new file mode 100644
index 0000000..61bfaa6
--- /dev/null
+++ b/src/components/ResponsiveImage.tsx
@@ -0,0 +1,56 @@
+/**
+ * avec srcset/sizes pré-rempli sur les variantes Karbé.
+ * Drop-in remplacement pour les balises ` ` côté front.
+ */
+
+import { buildSrcSet } from "@/lib/image-variants";
+
+type Props = {
+ src: string;
+ alt: string;
+ /** Indication CSS pour le browser. Ex: "(min-width: 768px) 800px, 100vw" */
+ sizes?: string;
+ className?: string;
+ loading?: "lazy" | "eager";
+ fetchPriority?: "high" | "low" | "auto";
+ width?: number;
+ height?: number;
+ decoding?: "async" | "sync" | "auto";
+ draggable?: boolean;
+ style?: React.CSSProperties;
+ onClick?: () => void;
+};
+
+export function ResponsiveImage({
+ src,
+ alt,
+ sizes = "(min-width: 768px) 800px, 100vw",
+ className,
+ loading = "lazy",
+ fetchPriority = "auto",
+ width,
+ height,
+ decoding = "async",
+ draggable,
+ style,
+ onClick,
+}: Props) {
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+}
diff --git a/src/lib/image-variants.ts b/src/lib/image-variants.ts
new file mode 100644
index 0000000..5d0e22a
--- /dev/null
+++ b/src/lib/image-variants.ts
@@ -0,0 +1,41 @@
+/**
+ * Variantes responsive : génération + URL helpers.
+ *
+ * Convention de nommage : .jpg -> -320.jpg, -800.jpg, -1600.jpg.
+ * Le format est forcé à JPEG pour les variantes (compression efficace,
+ * supporté partout). L'original reste tel quel (PNG/WebP/AVIF préservés).
+ *
+ * Helper côté client : variantUrl(originalUrl, width) → URL de la variante.
+ * Le browser fait le fallback automatiquement via srcset si la variante 404.
+ */
+
+export const VARIANT_WIDTHS = [320, 800, 1600] as const;
+export type VariantWidth = (typeof VARIANT_WIDTHS)[number];
+
+/** Calcule l'URL d'une variante depuis l'URL originale. */
+export function variantUrl(originalUrl: string, width: VariantWidth): string {
+ const lastDot = originalUrl.lastIndexOf(".");
+ if (lastDot === -1) return originalUrl;
+ const base = originalUrl.slice(0, lastDot);
+ return `${base}-${width}.jpg`;
+}
+
+/** Calcule la s3Key d'une variante depuis la s3Key originale. */
+export function variantS3Key(originalKey: string, width: VariantWidth): string {
+ const lastDot = originalKey.lastIndexOf(".");
+ if (lastDot === -1) return originalKey;
+ const base = originalKey.slice(0, lastDot);
+ return `${base}-${width}.jpg`;
+}
+
+/**
+ * srcSet attribut pour un ` `. Le browser pick la meilleure variante
+ * selon viewport+DPR. Si une variante 404, srcset fallback en cascade ;
+ * on ajoute toujours l'original comme dernière entrée pour garantir
+ * qu'au moins UNE source fonctionne.
+ */
+export function buildSrcSet(originalUrl: string): string {
+ return VARIANT_WIDTHS.map((w) => `${variantUrl(originalUrl, w)} ${w}w`)
+ .concat([`${originalUrl} 2000w`])
+ .join(", ");
+}
diff --git a/src/lib/variants-server.ts b/src/lib/variants-server.ts
new file mode 100644
index 0000000..06f3177
--- /dev/null
+++ b/src/lib/variants-server.ts
@@ -0,0 +1,126 @@
+/**
+ * Génération de variantes responsive côté serveur (Node).
+ *
+ * - Télécharge l'original depuis MinIO via l'endpoint interne.
+ * - sharp → 3 variantes (320 / 800 / 1600 px de large max, JPEG quality 80).
+ * - Upload chaque variante avec naming convention -.jpg.
+ * - Skippe vidéos (sharp ne les traite pas).
+ *
+ * Best-effort : si une variante échoue, on log et on continue. L'original
+ * fonctionne toujours côté front grâce au srcset fallback.
+ */
+
+import "server-only";
+
+import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
+import type { Readable } from "node:stream";
+
+import { VARIANT_WIDTHS, variantS3Key, type VariantWidth } from "./image-variants";
+
+const ENDPOINT = process.env.S3_ENDPOINT ?? "";
+const BUCKET = process.env.S3_BUCKET ?? "";
+const REGION = process.env.S3_REGION ?? "us-east-1";
+const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "";
+const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "";
+
+const s3 = new S3Client({
+ endpoint: ENDPOINT,
+ region: REGION,
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
+});
+
+async function streamToBuffer(stream: Readable | ReadableStream): Promise {
+ if ("getReader" in stream) {
+ const reader = stream.getReader();
+ const chunks: Uint8Array[] = [];
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ if (value) chunks.push(value);
+ }
+ return Buffer.concat(chunks);
+ }
+ const chunks: Buffer[] = [];
+ for await (const c of stream as Readable) chunks.push(c as Buffer);
+ return Buffer.concat(chunks);
+}
+
+export type VariantResult = {
+ width: VariantWidth;
+ s3Key: string;
+ ok: boolean;
+ reason?: string;
+};
+
+/**
+ * Génère les 3 variantes responsives pour une image originale.
+ * Skip silencieusement si mime === video/*.
+ */
+export async function generateImageVariants(opts: {
+ originalS3Key: string;
+ mime: string;
+}): Promise<{ skipped: boolean; results: VariantResult[] }> {
+ if (opts.mime.startsWith("video/")) {
+ return { skipped: true, results: [] };
+ }
+
+ let sharp: (input: Buffer) => import("sharp").Sharp;
+ try {
+ const mod = await import("sharp");
+ sharp = (mod as unknown as { default: (input: Buffer) => import("sharp").Sharp }).default;
+ } catch {
+ return { skipped: true, results: [] };
+ }
+
+ // 1. Download original
+ let originalBuffer: Buffer;
+ try {
+ const get = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: opts.originalS3Key }));
+ if (!get.Body) throw new Error("Empty body");
+ originalBuffer = await streamToBuffer(get.Body as Readable);
+ } catch (e) {
+ return {
+ skipped: false,
+ results: VARIANT_WIDTHS.map((w) => ({
+ width: w,
+ s3Key: variantS3Key(opts.originalS3Key, w),
+ ok: false,
+ reason: e instanceof Error ? e.message : "download failed",
+ })),
+ };
+ }
+
+ // 2. Variantes en parallèle
+ const results = await Promise.all(
+ VARIANT_WIDTHS.map(async (w): Promise => {
+ const targetKey = variantS3Key(opts.originalS3Key, w);
+ try {
+ const buf = await sharp(originalBuffer)
+ .rotate() // respecte l'EXIF orientation
+ .resize({ width: w, withoutEnlargement: true })
+ .jpeg({ quality: 80, progressive: true, mozjpeg: true })
+ .toBuffer();
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: targetKey,
+ Body: buf,
+ ContentType: "image/jpeg",
+ CacheControl: "public, max-age=31536000, immutable",
+ }),
+ );
+ return { width: w, s3Key: targetKey, ok: true };
+ } catch (e) {
+ return {
+ width: w,
+ s3Key: targetKey,
+ ok: false,
+ reason: e instanceof Error ? e.message : "resize/upload failed",
+ };
+ }
+ }),
+ );
+
+ return { skipped: false, results };
+}
diff --git a/tests/lib/image-variants.test.ts b/tests/lib/image-variants.test.ts
new file mode 100644
index 0000000..18fec19
--- /dev/null
+++ b/tests/lib/image-variants.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+
+import { VARIANT_WIDTHS, buildSrcSet, variantS3Key, variantUrl } from "@/lib/image-variants";
+
+describe("VARIANT_WIDTHS", () => {
+ it("contient 320, 800, 1600", () => {
+ expect(VARIANT_WIDTHS).toEqual([320, 800, 1600]);
+ });
+});
+
+describe("variantUrl", () => {
+ it("transforme .jpg en -320.jpg", () => {
+ expect(variantUrl("https://x/y/abc.jpg", 320)).toBe("https://x/y/abc-320.jpg");
+ });
+ it("force JPEG sortie même pour PNG/WebP en input", () => {
+ expect(variantUrl("https://x/y/abc.png", 800)).toBe("https://x/y/abc-800.jpg");
+ expect(variantUrl("https://x/y/abc.webp", 1600)).toBe("https://x/y/abc-1600.jpg");
+ });
+ it("renvoie l'original si pas d'extension", () => {
+ expect(variantUrl("https://x/y/abc", 320)).toBe("https://x/y/abc");
+ });
+});
+
+describe("variantS3Key", () => {
+ it("transforme correctement la s3Key", () => {
+ expect(variantS3Key("carbets/foo/123-abc.jpg", 800)).toBe("carbets/foo/123-abc-800.jpg");
+ });
+});
+
+describe("buildSrcSet", () => {
+ it("contient les 3 variantes + fallback original", () => {
+ const set = buildSrcSet("https://x/abc.jpg");
+ expect(set).toContain("abc-320.jpg 320w");
+ expect(set).toContain("abc-800.jpg 800w");
+ expect(set).toContain("abc-1600.jpg 1600w");
+ expect(set).toContain("abc.jpg 2000w");
+ });
+});
From 4fb7c948ad608253f0f78706d37398d6b948e6ec Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 01:27:20 +0000
Subject: [PATCH 09/34] feat(cron): regenerate-variants task pour batch tous
les Media existants
---
src/lib/scheduled.ts | 38 +++++++++++++++++++++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/src/lib/scheduled.ts b/src/lib/scheduled.ts
index f9faee8..d3272f8 100644
--- a/src/lib/scheduled.ts
+++ b/src/lib/scheduled.ts
@@ -5,10 +5,11 @@
import "server-only";
-import { BookingStatus } from "@/generated/prisma/enums";
+import { BookingStatus, MediaType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { purgeExpiredResetTokens } from "@/lib/password-reset";
+import { generateImageVariants } from "@/lib/variants-server";
const PENDING_TTL_DAYS = 7;
@@ -67,9 +68,44 @@ export async function listUpcomingArrivalsInThreeDays() {
});
}
+/** Régénère les variantes responsives pour tous les Media PHOTO en base. */
+export async function regenerateAllVariants(): Promise<{ scanned: number; ok: number; skipped: number; failed: number }> {
+ const medias = await prisma.media.findMany({
+ where: { type: MediaType.PHOTO },
+ select: { id: true, s3Key: true },
+ });
+ let ok = 0;
+ let skipped = 0;
+ let failed = 0;
+ for (const m of medias) {
+ const ext = m.s3Key.split(".").pop()?.toLowerCase();
+ if (!ext || !["jpg", "jpeg", "png", "webp", "avif"].includes(ext)) {
+ skipped++;
+ continue;
+ }
+ const mime =
+ ext === "png" ? "image/png" :
+ ext === "webp" ? "image/webp" :
+ ext === "avif" ? "image/avif" :
+ "image/jpeg";
+ const result = await generateImageVariants({ originalS3Key: m.s3Key, mime });
+ if (result.skipped) skipped++;
+ else if (result.results.every((r) => r.ok)) ok++;
+ else failed++;
+ }
+ await recordAudit({
+ scope: "cron",
+ event: "variants.regenerate-all",
+ actorEmail: null,
+ details: { scanned: medias.length, ok, skipped, failed },
+ });
+ return { scanned: medias.length, ok, skipped, failed };
+}
+
export const SCHEDULED_TASKS = {
"auto-cancel-stale-pending": autoCancelStalePending,
"purge-reset-tokens": purgeResetTokens,
+ "regenerate-variants": regenerateAllVariants,
} as const;
export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS;
From bc158ca144dec17e68949719b4ad8cb7853dc31a Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 01:53:22 +0000
Subject: [PATCH 10/34] =?UTF-8?q?feat(pwa):=20manifest=20+=20ic=C3=B4nes?=
=?UTF-8?q?=20192/512/maskable=20+=20Apple=20touch=20+=20viewport=20theme-?=
=?UTF-8?q?color?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
public/icons/apple-touch-icon.png | Bin 0 -> 1059 bytes
public/icons/favicon-32.png | Bin 0 -> 208 bytes
public/icons/icon-192-maskable.png | Bin 0 -> 1069 bytes
public/icons/icon-192.png | Bin 0 -> 1202 bytes
public/icons/icon-512-maskable.png | Bin 0 -> 3118 bytes
public/icons/icon-512.png | Bin 0 -> 3479 bytes
public/manifest.webmanifest | 60 +++++++++++++++++++++++++++++
src/app/layout.tsx | 22 +++++++++++
8 files changed, 82 insertions(+)
create mode 100644 public/icons/apple-touch-icon.png
create mode 100644 public/icons/favicon-32.png
create mode 100644 public/icons/icon-192-maskable.png
create mode 100644 public/icons/icon-192.png
create mode 100644 public/icons/icon-512-maskable.png
create mode 100644 public/icons/icon-512.png
create mode 100644 public/manifest.webmanifest
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..a185b6796551f6bded99d10c48959adfd315c2d7
GIT binary patch
literal 1059
zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbof%%oEi(^Q|oVRzp^KN@cv|jXD
zJbAAbchI+2f&3ghWTU4)n*G%xUG9QI?aywdrN2IY{uuUPX8pT=%qyl_bR2P1;ZrIU
z=xp|oae1U5#6FQqLjUiTKK#D@__q2Vug||v{x2jn8D_)9T=OMoDk`_vosHdTvC269
z*i`vzM*^qMG+OtcN58LYiP5{NEy{gSHoA}Rsnk*Odpm2_6?Z&Cl{;Wsg!{3^t
zFV+AVDQ`b2{+$|l)QrKk&
z)DdZxW4QIJVqdZ8-F6GV?PV=jF1K6gT}qOFw8O8sw&QWo>TNS_0}bPidg(0lJ;Kf@
z?~Sue`KmqrxA|5+J$z{E{(m(wg;$b1mBha%Mz1_Nsdaxvy`kl*&zl-fKWjf3ICpw>
n{mnlHmK{Dc$wcTG@`Zhcr?s15`92?D=4bG9^>bP0l+XkKi=OZ8
literal 0
HcmV?d00001
diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png
new file mode 100644
index 0000000000000000000000000000000000000000..c062acf25a431e2370c4a48921b89282250b7bfd
GIT binary patch
literal 208
zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WrhB?LhE&XXJI$M`!GOmlI`F*|
zzsUPrQ@foCl4nXr=_G}*Cd%o*Jice=(urGneE%~BGhAEFf7IsM@5&Pm-+3l*oo-u{
z*RVwW=7MF)n=kSnIH}!`)c0O{oohwsHK6}Bxf=%@Bqzj6t
zW0lGR4Kz64E#U0=8D|paWVk>gsk^|f{q?T1C;qR0{GCx;KFiAItmga!KqoSIy85}S
Ib4q9e045hvoB#j-
literal 0
HcmV?d00001
diff --git a/public/icons/icon-192-maskable.png b/public/icons/icon-192-maskable.png
new file mode 100644
index 0000000000000000000000000000000000000000..e80f81192a1e0b24809cb8e17670bfea31e1c067
GIT binary patch
literal 1069
zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf%%K4i(^Q|oVRx``aN=BXnk0?
z`PaH14nNk#y>;zP6imEjocr?3_t?By91qXWT-UL0&pdzDX}>mqXMS*l#l^+JW#EZ_
z@BT7m)y_L-I(={Lp7nn{t`+>hY5RGym5+n$?%?yr%Z&?LY}Y?uzdT~W-Me$K2&Zy_pJX$hwRrc?PJU%7l@X|19{pnzxF*>=e~09
zd{x!l`ALnpUOl+`b*D~%@nf0j={f<-?f+(8nZWAR8v|6^X?T8(wphl(_OCq#_D7j?
z1B`8&uLQH0c;A=Li#aA3c3Sqluh@%?kuP~nfzq~1=Ka>WuzpUYsm_I5?pKlpbzQ8#
zGH-t9i3A!IE)!iGxj^63>hL-Lm--7*$vuHLzd$xWP*^U;h2;y~ke7{33JVi1yx9|CqI;qAm+C?ktbOz2eKS+6A-vZ<$p7{QbK%u((tr;OTD3{k5A54^Effxp4aR&zbL!
z2c+(m&&Z#2aRcwy8vc-#C$sI}{GHI?buqxfA%51L_l611Oux==*lyI#`6BAZg$!kt
z1=5Ee=d5UB`J&BxRp5fHRF#JVzsm8O9K4(>y4vcL7equBBQH`%7QBofQlPgk4KsV>6wPoLIK{)W-1GgJ(Ej!&-ZXxEwNmY
zac$}~5!vlPdi8JlO=1^bnf-8`Z!L5|OsjvhGEnW?lA9A6%&x{eGu?_hTUfTBjb%%J
zo|Mpqyk8j}4%Jl^b8PE@Quh~1Tb;?*cx)M|pv0u4giKifS=l_x-u1z(+;t~&fAt>u
zkiTHZvWKQ?bHp!v-@L1eZ~FgtKq<|)IU*Onym)W(W!CcNv%;>M4|(<#a4diTVkfbVt~QZ)z4*}Q$iB}>-D)j
literal 0
HcmV?d00001
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000000000000000000000000000000000000..cb0fd1351de30e4ac11173a6c54cdeb59338e79e
GIT binary patch
literal 1202
zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfu+*Z#WAE}&fB{ei*6YRv_AAz
zKe>AnyVCnvtK)V!uQ+(@(!@1)ul|oMn{%LLzq`SNZ*L!087{as|6)1AMnM4;78XuM
zM<<5~0(4@Y{Qbf@A?;i7&S`ZgpMMYUuYPoGqWr}P^E+0rQPu7`WzP;+t^34g2GMwT48y)Vkc!3mtEZ*6+`gnu_|Fp^4Nw#hd$)Pm@r=cZ1Zt9YB*y_W6LwWi00_odC&mYedCXIKxc!mHjgViS#POaGd>J7
z62y>0DSJ_VSFe{V}Gy0sgXLE1u3YjkXmtUl6u`-f?5Oz$*>YlJfZL
zUH#8qo>@?*ljqQV{{8ypvTwd$NiKc1=J+D^l-s;_AJ2KEet7i;pv*>QgSUSo`WnO*
z%N?EhdZQ2H&5q+mpQ>~s9iBa~nC-i*LCmCN)*Qx3lJl6a&U}^lF8yA^n%l;&SIz_a>OxbS2uq5a
zGZWLV<;M%noSj*|$V+LtI%J!Kv^31isGFmj*U?~h@q!@BmiD$dr3K=*GEz_!tuPnST2rynFJnD`tOu>{Jnb>9=pkNoXPUU>0;rttQL+F6FYbX
zC+I(vQc@`IlTVie(#sbC?Wk+b=xB(su9(9M)W`JU&V=s&+RTL;@n*%7)nCKc-P>FJ
zt4;p;lig`cpPjuj*Ss#@{G{p6b(O0u7TnfdyYKzq`Nh8bvU8jx9Zr|u`B~+QdQ4^#x9i
zDAFY$Xk0P_I?|evQeZ^6R3)NfF=%hpj;t
zaS{$Oj>T+fUlU+EW53=7giDdVvXUbFWtT=#s$lZr?Fw731jz4|_6MEd_3mYFy|I2S
z8)ozbB0xL?UUv(SQB{j!FU6MSS{f{^<~YvS>1;SrXtzG4fpw=z*9li%eLzTV5>A|m
zE30o&CF8_sk-hFQ4K_uD@?mTDZH=N$b(#s!KlUT42j|1aL{sL2neM&=V+g2KNkCmz
zZOkxzn92s*U0>j+MScOLM6rJ7UV?NgIK78^UIOixR#-e8
zs5gSwrdq*
z3jUf(Xx6k5`opG3dZp5S+Ndp|66J%u>x)=U3aR5!BEAUwSxF`KS|@B@1>nuHW)_uL
z+EJGH-gav?MYJo$`^h@!OnANe+VI>jia}ja)!+NeP%fP!io6lq6tABZh>>@_b!+|R
zQw($#;M0IujI2yshnu3R^ToVq=+vDp(D`WIoX_}{g;0CqnyU1klQ44GXKTzm{!ka`
zu@EG_D=hlIzVtmk6@F?j$wnkF#HiiP{`sS!O9vUi_Opv3?oK;jE?H^OANf+=T7o9X
zD6V}H4)X5&d`s!R+74MWHx4g64a28gXr}G?n{WeQWKTM$IqqqL$}z5-^9)kvLHED%
z_tmvMEdF}tGbr34M_HlnNKI0^Hi>
zl!oVk-*hIyWI%q=NOX1};m?3AtsuXQF01h~3RA?x$Zj6YT?jF8xWOMIUHks&)(W(F
zP_Z62EW^mn3)7$^7PdC~>egO>*hNqgg&S^CiR4r`gxp;-bZc4U7gS<273!j4!!t1F
z!$yDmfpaveALU9oZa4$yN-4w0$um2hlPOoC@l(^GH6mdWkGR7C4ggHP4GQHM;!Sh~%OG7pl1iUYCUkZAC3;`_iXDZlt{y5;p6fMCZ
zs0E(S2k8=~$R7jQ?02g-;PUf8_V9>6I)v;ji5KGXLLhs2#B~OQgyV$;KvvRfQLSHn
zK+R-|R%1}y0)5_~{@c_aj`IjM10?8V&_r0qBSsi7n0#Wu%X|XJyRj0&htMKVEIvCQ
z$f>b)D2u=wq#UMbF$OuU&^QmIKUO-kAZGo~oU%zTZb5P*HOM9&QAvlAIDF+vAl-B8
z5ts_Js}xQYh-nv7flBS+XA;vjs*WJRSKvwXGMQA$)muCan{qe3_D{+zm6-WmHNN*m2#&pFl
z9l~xs3uM}F4f}4uYY?f6A8(`JWI9yD;LeU;@rbkNA)_1x7hVG4$qbL)Z$bF5?*`!`
zO2c3ki;Tk{oJEF%*Pa*7pga&>qdo6N6w?Ry-lal(DINo>QEqm-)>^i9Zj4h%T;
i+uZnl`5#i^ngbW)W=P8Q5vk}u0HVTU!+uyNI`$u7ttJxy
literal 0
HcmV?d00001
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000000000000000000000000000000000000..abb04bf9ea494914cd2ccb8303170d0f2be0cde0
GIT binary patch
literal 3479
zcmeHK>sM1(7T@>Y5E7O_Anj5TkRXZ(NTZ}dk(Qvy%P1@>RVyXI2cR;7C?izuy*vaT
zR9K1{YDTc@l#*o5^`Dse&@Var?6V)g{Wv@4
z9*^m2%|`fJ2bj1CXmnEl0XBkMBXy6grx3U4(B|xIG^))S${heH+ES)?vPLZVnIgT2bY_B
z$pSxt%2#I-@FC;knNzk1k}C$ie~nF_2HM443Tp(pRSYVou6YF?dJ-QQkctbU6h_%m
z4$Ljblh7e;_&GY%7ZqG-^WMv%4esL8cJD*Iz
zKbKjqB0RB&-$!`%Bi#~(m5Ug%rgCe9Z3l|mx5joDEk{U{58E{cXbc0$@A#MlOVe#q
zR&c;v)Id7&Ve-7u<9jCfcho5-*Jq(vl*X54qn{M<~=s6A~|6$UnPcLe~wAZENJhT)Hn;Qxd
zzabN8ZYQXmYoX=~v@J#99*-NyQqI=MWpY9up<{xp^x+>J~xIkSneFZUPn-TNCO&1gerVv}!*J)_(7dYc2-@
zvYsN<6fPeU+Ds`vV$dK}<_?0=eJr%N+lKBiVNuf<3b&zz16|vu1DEsR`gp6-B@KE8
z&(fH^5Y_p;J*iXXGk}*dzIsg^rLPU3j|^#`meuDs8)H$kqGq`q%!jnGR^~2Yej8Q=qxzw`aGthp$FCz4d_P3p8wqHjz
z?9M?lk5b=^o$K>{6y34wnl}YAUS#Z9wCOYjj5*9?})*B<6cx;-BAwp
zRTb^Wk@?5ZQIj
zg}D0+8jEY8pP*f6sa(PbyUwN4Ng`a>e8ITo1tVBzdgt|j$}d3Y%YmLh10zDabxD`|
zq*bJ=`91Dh*f5h#T6R~QYMd_>TaqNW(~~gb{il!f7f0FzTTr(OI)2RR;mwh`mRmDi
zv8d~rZ=2hr{qB~%@u`4<;6bY7bCbN^INOU$C9JH=?;reRr|31^13U22x}SX~&(?of
z(D(*^2&TUlulLh+AE~|j8viAz?ct4Ev8{_>W@qxhe_6L74<<{{uqqq~OBBSj;NaY(
zQVD2ci9cRF_6Y~`>1V31Zw2gjh6>>W`G>f6KTlowZTJdY>syyUZz{7k3I?@`n;m;9
zeK0xyo@0$CYK>slm;jzvV{u=Cx~=$>cs;#e?u
zOr&>o?8v~i=`{~sZ^cJ=L&AZ{63gQ-o>pyrX2B+&SHC)NaM(8ey*Af8_3M%wWU&-7
z-*s!bQ1htd^(GGG%#zgRdztnAI+&XaEkjFly60{!A3*wXDEaNVhlWZW66{3OK9=N!
z{r(#-L;7H14a^^w4))f>>a$9j!o2j+(Zo;4!t&3A4;(n3J0}jDb+Ggc7n07!xz(JO
z>-wcUOzt20SXQ-6g&kM`#ZiPF$F(XEba_Lu-*3UJ0JH{FW9IPN+OFm7?I8fTQQ(l4u^Zu_1mJxGjLU#1`Z~!xvM9aHt~;$~6_93qZQ<1uZ)Z
z*KQO+KNpg#F!gB=Rvxd<7g2=_YS%6Z@rF(*p~ooT2YT+IJ!Vy
z=Rx;s%*_*8dcnK~qC`W!D(txzJn4hGZ0HqHJ8(%;c^xvjj)Cs!#dry^ITrLm909u2
zGF<|)x`zeBeXt7sR$>dBK$Cuo#)V;2q=-7shLB3EGY0gHY}!aS1NyH#28}1E!;H+$
zI8CfG((+yy^`3}gv*gbk)@x_CWXjwAny)1Y+{QE0vtuOc)G&`MgHk?nF8%!(v5
zg`hpgl-Y^ojyM?lVEsZg={fok9IE(#21gMwT&N8rQrClO7Du%NqhbngBi$3|zr(cw
zEAxW5K8mAe(8NOu^o_=JIc9#89y!w0=|l@&k6Az9%v
zj9Re^V%Z>FOJqwyo@_vs&{7N-yNnGl1gE4@RH(xF;1o`nqd;(yIavgfFP!N>88}pm
z0HYE{_!1coz0-;ITkvTYP>rL?yR$*<&vS|fYBZS6?Hn9tRuh@%HM5pL70!sPN0>CH
z0c3qtBOA?mrc?^lq9)qA9RoJ6A;!Ew`w>s*ih)>TMOo-V`(&Qb9)mwL*>CqyfhBl&@WesJOE<_1}pL(cF23=t^d{meAAh0Sn;_4_D1}s0yL}P$zNXS4{
zj6_HRICJ~IeVP8w?N9>T2mdc#$q&feq1tc!JG#$*TVgXCEDr-KUKOV-Q#SU|K;3P>
z2h|&@LhD=Kuw5z|6T4w1$16x?Ym>b$P6;uT7o?n<*JP(3*ksjrBu?!PF}QYTC!b1b
u5@|hfqTuOF3V>-F@%NX&o6n)&$?x8~-{+h+YZLn5g6K8t!|PVb^ZpC!gyZ=D
literal 0
HcmV?d00001
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..2f32e8d
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,60 @@
+{
+ "name": "Karbé — carbets fluviaux de Guyane",
+ "short_name": "Karbé",
+ "description": "Au fil de l'eau : louez des carbets le long des fleuves de Guyane.",
+ "start_url": "/decouvrir",
+ "id": "/decouvrir",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#000000",
+ "theme_color": "#059669",
+ "lang": "fr",
+ "categories": ["travel", "lifestyle"],
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-192-maskable.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-512-maskable.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Au fil de l'eau",
+ "short_name": "Découvrir",
+ "url": "/decouvrir",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Mes favoris",
+ "short_name": "Favoris",
+ "url": "/mes-favoris",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Mon compte",
+ "short_name": "Compte",
+ "url": "/mon-compte",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ }
+ ]
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 2a05155..1e1dc82 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -52,6 +52,21 @@ export const metadata: Metadata = {
},
description:
"Karbé, la marketplace de location de carbets fluviaux de Guyane.",
+ manifest: "/manifest.webmanifest",
+ applicationName: "Karbé",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "black-translucent",
+ title: "Karbé",
+ },
+ icons: {
+ icon: [
+ { url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
+ { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
+ ],
+ apple: "/icons/apple-touch-icon.png",
+ },
openGraph: {
type: "website",
siteName: "Karbé",
@@ -62,6 +77,13 @@ export const metadata: Metadata = {
},
};
+export const viewport = {
+ themeColor: "#059669",
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover" as const,
+};
+
export default async function RootLayout({
children,
}: Readonly<{
From d5732917e318a4e9fa6a96bacaa95c1826f24a82 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 02:03:23 +0000
Subject: [PATCH 11/34] =?UTF-8?q?feat(reels):=20swipe=20horizontal=20anim?=
=?UTF-8?q?=C3=A9=20avec=20suivi=20du=20doigt=20+=20snap?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/decouvrir/_components/ReelSlide.tsx | 290 +++++++++++++++-----
1 file changed, 216 insertions(+), 74 deletions(-)
diff --git a/src/app/decouvrir/_components/ReelSlide.tsx b/src/app/decouvrir/_components/ReelSlide.tsx
index a8476f4..7c1b4e7 100644
--- a/src/app/decouvrir/_components/ReelSlide.tsx
+++ b/src/app/decouvrir/_components/ReelSlide.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import type { ReelCarbet } from "@/lib/reels";
@@ -14,36 +14,71 @@ type Props = {
onToggleFavorite: () => void;
};
+const SWIPE_THRESHOLD_RATIO = 0.18; // % de la largeur pour valider le swipe
+const VELOCITY_THRESHOLD = 0.4; // px/ms — un flick rapide même court valide
+
export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) {
const [mediaIndex, setMediaIndex] = useState(0);
const [muted, setMuted] = useState(true);
- const touchStart = useRef<{ x: number; y: number } | null>(null);
- const videoRef = useRef(null);
+ const [dragX, setDragX] = useState(0);
+ const [transitioning, setTransitioning] = useState(false);
+ const [containerWidth, setContainerWidth] = useState(0);
+ const containerRef = useRef(null);
+ const videoRefs = useRef>(new Map());
+ const drag = useRef<{
+ startX: number;
+ startY: number;
+ startTime: number;
+ locked: "horizontal" | "vertical" | null;
+ } | null>(null);
+ const total = carbet.media.length;
const current = carbet.media[mediaIndex];
- const nextMedia = useCallback(() => {
- setMediaIndex((i) => (i + 1) % carbet.media.length);
- }, [carbet.media.length]);
- const prevMedia = useCallback(() => {
- setMediaIndex((i) => (i - 1 + carbet.media.length) % carbet.media.length);
- }, [carbet.media.length]);
+ const goTo = useCallback(
+ (next: number, animated = true) => {
+ const clamped = ((next % total) + total) % total;
+ setTransitioning(animated);
+ setMediaIndex(clamped);
+ setDragX(0);
+ },
+ [total],
+ );
- // Auto-play/pause vidéos quand slide active
+ const nextMedia = useCallback(() => goTo(mediaIndex + 1), [goTo, mediaIndex]);
+ const prevMedia = useCallback(() => goTo(mediaIndex - 1), [goTo, mediaIndex]);
+
+ // Suit la largeur du container pour les calculs de seuils / progress
useEffect(() => {
- if (!videoRef.current) return;
- if (isActive && current?.type === "VIDEO") {
- videoRef.current.play().catch(() => {});
- } else {
- videoRef.current.pause();
- }
- }, [isActive, current?.type, mediaIndex]);
+ const el = containerRef.current;
+ if (!el) return;
+ const update = () => setContainerWidth(el.offsetWidth || window.innerWidth);
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ window.addEventListener("resize", update);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
- // Reset au changement de slide (différé pour éviter cascading renders)
+ // Auto-play/pause vidéos selon média actif
+ useEffect(() => {
+ videoRefs.current.forEach((video, idx) => {
+ if (idx === mediaIndex && isActive && carbet.media[idx]?.type === "VIDEO") {
+ video.play().catch(() => {});
+ } else {
+ video.pause();
+ }
+ });
+ }, [isActive, mediaIndex, carbet.media]);
+
+ // Reset au changement de slide carbet (différé pour éviter cascading renders)
useEffect(() => {
if (isActive) return;
- queueMicrotask(() => setMediaIndex(0));
- }, [isActive]);
+ queueMicrotask(() => goTo(0, false));
+ }, [isActive, goTo]);
// Navigation clavier ← →
useEffect(() => {
@@ -65,21 +100,88 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl
function onTouchStart(e: React.TouchEvent) {
const t = e.touches[0];
- touchStart.current = { x: t.clientX, y: t.clientY };
+ drag.current = {
+ startX: t.clientX,
+ startY: t.clientY,
+ startTime: Date.now(),
+ locked: null,
+ };
+ setTransitioning(false);
}
- function onTouchEnd(e: React.TouchEvent) {
- if (!touchStart.current) return;
- const t = e.changedTouches[0];
- const dx = t.clientX - touchStart.current.x;
- const dy = t.clientY - touchStart.current.y;
- touchStart.current = null;
- // Seuil horizontal > vertical pour considérer un swipe horizontal
- if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.2) {
- if (dx < 0) nextMedia();
- else prevMedia();
+
+ function onTouchMove(e: React.TouchEvent) {
+ if (!drag.current) return;
+ const t = e.touches[0];
+ const dx = t.clientX - drag.current.startX;
+ const dy = t.clientY - drag.current.startY;
+
+ // Première détection : verrouille l'axe (horizontal ou vertical)
+ if (drag.current.locked === null) {
+ if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return; // trop petit, attend
+ drag.current.locked = Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
+ }
+
+ if (drag.current.locked !== "horizontal") return;
+ // Empêche le scroll vertical pendant un swipe horizontal
+ e.stopPropagation();
+ if (e.cancelable) e.preventDefault();
+
+ // Résistance aux bords : si on swipe gauche sur le 1er ou droite sur le dernier,
+ // on glisse moins (effet rubber-band)
+ let effective = dx;
+ if (total <= 1) {
+ effective = dx * 0.2;
+ } else if (mediaIndex === 0 && dx > 0) {
+ effective = dx * 0.35;
+ } else if (mediaIndex === total - 1 && dx < 0) {
+ effective = dx * 0.35;
+ }
+ setDragX(effective);
+ }
+
+ function onTouchEnd() {
+ if (!drag.current) return;
+ const wasHorizontal = drag.current.locked === "horizontal";
+ const elapsed = Date.now() - drag.current.startTime;
+ const width = containerWidth || window.innerWidth;
+ const velocity = Math.abs(dragX) / Math.max(1, elapsed); // px/ms
+ drag.current = null;
+
+ if (!wasHorizontal) {
+ setDragX(0);
+ return;
+ }
+
+ const distance = Math.abs(dragX);
+ const isFlick = velocity > VELOCITY_THRESHOLD && distance > 20;
+ const isSlow = distance > width * SWIPE_THRESHOLD_RATIO;
+ const shouldChange = (isFlick || isSlow) && total > 1;
+
+ if (shouldChange) {
+ if (dragX < 0 && mediaIndex < total - 1) {
+ goTo(mediaIndex + 1);
+ } else if (dragX > 0 && mediaIndex > 0) {
+ goTo(mediaIndex - 1);
+ } else {
+ // Bord : retour à 0
+ setTransitioning(true);
+ setDragX(0);
+ }
+ } else {
+ setTransitioning(true);
+ setDragX(0);
}
}
+ // Préchargement intelligent : current, current ± 1
+ const preloadIndexes = useMemo(() => {
+ const s = new Set();
+ s.add(mediaIndex);
+ if (mediaIndex > 0) s.add(mediaIndex - 1);
+ if (mediaIndex < total - 1) s.add(mediaIndex + 1);
+ return s;
+ }, [mediaIndex, total]);
+
const share = useCallback(async () => {
const url = `${window.location.origin}/carbets/${carbet.slug}`;
const title = carbet.title;
@@ -92,57 +194,97 @@ export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggl
if (!current) return null;
+ const offsetPct = -mediaIndex * 100;
+
return (
-
- {/* Média */}
-
- {current.type === "VIDEO" ? (
-
setMuted((m) => !m)}
- />
- ) : (
- // eslint-disable-next-line @next/next/no-img-element
-
- )}
+
+ {/* Track : tous les médias en ligne, transformX selon index + drag */}
+
setTransitioning(false)}
+ >
+ {carbet.media.map((m, idx) => {
+ const visible = preloadIndexes.has(idx) || shouldPreload;
+ return (
+
+ {m.type === "VIDEO" ? (
+
{
+ if (el) videoRefs.current.set(idx, el);
+ else videoRefs.current.delete(idx);
+ }}
+ src={visible ? m.url : undefined}
+ muted={muted}
+ playsInline
+ loop
+ preload={visible ? "auto" : "none"}
+ className="h-full w-full object-cover"
+ />
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ );
+ })}
{/* Voile dégradé en bas pour lisibilité */}
{/* Indicateurs progression médias (sticks en haut) */}
- {carbet.media.length > 1 ? (
+ {total > 1 ? (
- {carbet.media.map((_, i) => (
-
- ))}
+ {carbet.media.map((_, i) => {
+ const isActiveStick = i === mediaIndex;
+ const wasSeen = i < mediaIndex;
+ // Progression visuelle pendant le drag (preview du swipe)
+ const progress = isActiveStick && Math.abs(dragX) > 0 && containerWidth > 0
+ ? Math.min(1, Math.abs(dragX) / containerWidth)
+ : 0;
+ return (
+
+ 0 ? { width: `${progress * 100}%` } : undefined}
+ />
+
+ );
+ })}
) : null}
From dc2b07507fe23290815c6c6f1ad61f567ad8cbb8 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 02:26:02 +0000
Subject: [PATCH 12/34] =?UTF-8?q?feat:=204=20crit=C3=A8res=20op=C3=A9ratio?=
=?UTF-8?q?nnels=20(route/capacit=C3=A9/=C3=A9lectricit=C3=A9/GSM)=20+=20p?=
=?UTF-8?q?resets=20profils=20+=20badges?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../migration.sql | 15 +++
prisma/schema.prisma | 18 +++
src/app/carbets/[slug]/page.tsx | 15 +++
src/app/carbets/_components/carbet-card.tsx | 14 +-
.../carbets/_components/search-filters.tsx | 110 +++++++++++++++-
.../carbets/_components/search-profiles.tsx | 29 +++++
src/app/carbets/page.tsx | 2 +
src/components/OperationalBadges.tsx | 120 ++++++++++++++++++
src/lib/carbet-public.ts | 12 ++
src/lib/carbet-search.ts | 88 ++++++++++++-
src/lib/search-profiles.ts | 79 ++++++++++++
11 files changed, 494 insertions(+), 8 deletions(-)
create mode 100644 prisma/migrations/20260602030000_operational_criteria/migration.sql
create mode 100644 src/app/carbets/_components/search-profiles.tsx
create mode 100644 src/components/OperationalBadges.tsx
create mode 100644 src/lib/search-profiles.ts
diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql
new file mode 100644
index 0000000..5bdca5f
--- /dev/null
+++ b/prisma/migrations/20260602030000_operational_criteria/migration.sql
@@ -0,0 +1,15 @@
+CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR');
+CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF');
+
+ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess";
+ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity";
+ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2);
+
+-- Seed des 6 carbets démo avec valeurs réalistes
+UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara';
+UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou';
+UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury';
+UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa';
+UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou';
+UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 83d75c2..0636340 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -124,6 +124,11 @@ 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.
@@ -381,3 +386,16 @@ model Favorite {
@@index([userId])
@@index([carbetId])
}
+
+enum RoadAccess {
+ NONE
+ DRY_SEASON_ONLY
+ ALL_YEAR
+}
+
+enum Electricity {
+ NONE
+ SOLAR
+ GENERATOR_READY
+ EDF
+}
diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx
index f37adae..ae53374 100644
--- a/src/app/carbets/[slug]/page.tsx
+++ b/src/app/carbets/[slug]/page.tsx
@@ -20,6 +20,7 @@ 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";
@@ -131,6 +132,20 @@ export default async function PublicCarbetPage({ params }: PageProps) {
+
+
+ Critères opérationnels
+
+
+
+
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx
index c11003a..f32cc8e 100644
--- a/src/app/carbets/_components/carbet-card.tsx
+++ b/src/app/carbets/_components/carbet-card.tsx
@@ -5,6 +5,7 @@ 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";
@@ -41,9 +42,18 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
- Fleuve {carbet.river} · {carbet.capacity} voyageur
- {carbet.capacity > 1 ? "s" : ""}
+ Fleuve {carbet.river}
+
+
+
- Voyageurs
+ Voyageurs min
+
+
+
+ Voyageurs max
+
@@ -87,6 +101,98 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) {
/>
+
+ Accès route
+
+ {[
+ { 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 (
+
+
+ {opt.label}
+
+ );
+ })}
+
+
+
+
+ Électricité
+
+ {[
+ { 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 (
+
+
+ {opt.label}
+
+ );
+ })}
+
+
+
+
+
+ 📶 Réseau GSM accessible — distance max{" "}
+
+ {filters.gsmMaxKm === 0 ? "(au carbet)" : filters.gsmMaxKm ? `≤ ${filters.gsmMaxKm} km` : "(non filtré)"}
+
+
+
+ Au carbet
+
+ 10 km
+
+
+ 0 km = exige le réseau directement au carbet · 10 km = peu importe.
+
+
+
Équipements souhaités
diff --git a/src/app/carbets/_components/search-profiles.tsx b/src/app/carbets/_components/search-profiles.tsx
new file mode 100644
index 0000000..cd11732
--- /dev/null
+++ b/src/app/carbets/_components/search-profiles.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import Link from "next/link";
+
+import { SEARCH_PROFILES, buildProfileUrl } from "@/lib/search-profiles";
+
+export function SearchProfiles() {
+ return (
+
+
+ Profils de séjour
+
+
+ {SEARCH_PROFILES.map((p) => (
+
+
+ {p.emoji}
+ {p.label}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx
index b700fed..512c79f 100644
--- a/src/app/carbets/page.tsx
+++ b/src/app/carbets/page.tsx
@@ -10,6 +10,7 @@ import {
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",
@@ -57,6 +58,7 @@ export default async function CarbetsSearchPage({
+
diff --git a/src/components/OperationalBadges.tsx b/src/components/OperationalBadges.tsx
new file mode 100644
index 0000000..e2f3ea0
--- /dev/null
+++ b/src/components/OperationalBadges.tsx
@@ -0,0 +1,120 @@
+/**
+ * Badges opérationnels Karbé : 4 critères dealbreakers affichés en compact
+ * sur les cards catalog + en gros sur la fiche carbet.
+ *
+ * - Route (NONE / DRY_SEASON_ONLY / ALL_YEAR)
+ * - Capacité (X voyageurs max)
+ * - Électricité (NONE / SOLAR / GENERATOR_READY / EDF)
+ * - GSM (au carbet OUI / à X km / zone blanche)
+ */
+
+import { Electricity, RoadAccess } from "@/generated/prisma/enums";
+
+type Props = {
+ roadAccess: RoadAccess | null;
+ capacity: number;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
+ /** "compact" pour les cards, "full" pour la fiche détail. */
+ variant?: "compact" | "full";
+};
+
+type Badge = {
+ emoji: string;
+ label: string;
+ tone: "good" | "neutral" | "warn";
+};
+
+function roadBadge(r: RoadAccess | null): Badge {
+ if (r === RoadAccess.ALL_YEAR) return { emoji: "🛣️", label: "Route toute saison", tone: "good" };
+ if (r === RoadAccess.DRY_SEASON_ONLY) return { emoji: "🛣️", label: "Route saison sèche", tone: "warn" };
+ if (r === RoadAccess.NONE) return { emoji: "🛶", label: "Pirogue uniquement", tone: "neutral" };
+ return { emoji: "🛣️", label: "Accès non précisé", tone: "neutral" };
+}
+
+function capacityBadge(c: number): Badge {
+ return { emoji: "👥", label: `${c} voyageur${c > 1 ? "s" : ""}`, tone: "neutral" };
+}
+
+function electricityBadge(e: Electricity | null): Badge {
+ if (e === Electricity.EDF) return { emoji: "⚡", label: "EDF / raccordé", tone: "good" };
+ if (e === Electricity.GENERATOR_READY) return { emoji: "🔌", label: "Préinstall groupe", tone: "good" };
+ if (e === Electricity.SOLAR) return { emoji: "☀️", label: "Solaire", tone: "neutral" };
+ if (e === Electricity.NONE) return { emoji: "🕯️", label: "Aucune électricité", tone: "warn" };
+ return { emoji: "⚡", label: "Électricité non précisée", tone: "neutral" };
+}
+
+function gsmBadge(atCarbet: boolean, exitKm: number | null): Badge {
+ if (atCarbet) return { emoji: "📶", label: "Réseau au carbet", tone: "good" };
+ if (exitKm !== null) {
+ const tone: Badge["tone"] = exitKm <= 1 ? "neutral" : "warn";
+ return { emoji: "📵", label: `Réseau à ${exitKm.toFixed(exitKm < 1 ? 1 : 0)} km`, tone };
+ }
+ return { emoji: "📵", label: "Zone blanche", tone: "warn" };
+}
+
+const TONE_CLASSES_COMPACT: Record = {
+ good: "bg-emerald-50 text-emerald-800 ring-emerald-200",
+ neutral: "bg-zinc-100 text-zinc-700 ring-zinc-200",
+ warn: "bg-amber-50 text-amber-800 ring-amber-200",
+};
+
+const TONE_CLASSES_FULL: Record = {
+ good: "bg-emerald-50 text-emerald-900 ring-emerald-300 border-emerald-200",
+ neutral: "bg-white text-zinc-900 ring-zinc-300 border-zinc-200",
+ warn: "bg-amber-50 text-amber-900 ring-amber-300 border-amber-200",
+};
+
+export function OperationalBadges({
+ roadAccess,
+ capacity,
+ electricity,
+ gsmAtCarbet,
+ gsmExitDistanceKm,
+ variant = "compact",
+}: Props) {
+ const badges: Badge[] = [
+ roadBadge(roadAccess),
+ capacityBadge(capacity),
+ electricityBadge(electricity),
+ gsmBadge(gsmAtCarbet, gsmExitDistanceKm),
+ ];
+
+ if (variant === "compact") {
+ return (
+
+ {badges.map((b, i) => (
+
+ {b.emoji}
+ {b.label}
+
+ ))}
+
+ );
+ }
+
+ // full : grille 2×2 pour la fiche
+ return (
+
+ {badges.map((b, i) => (
+
+ {b.emoji}
+ {b.label}
+
+ ))}
+
+ );
+}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
index 61af5c4..c09b2fb 100644
--- a/src/lib/carbet-public.ts
+++ b/src/lib/carbet-public.ts
@@ -28,6 +28,10 @@ export type PublicCarbetDetail = {
roadAccessNote: string | null;
capacity: number;
nightlyPrice: string;
+ roadAccess: import("@/generated/prisma/enums").RoadAccess | null;
+ electricity: import("@/generated/prisma/enums").Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
minStayNights: number | null;
maxStayNights: number | null;
minCapacity: number | null;
@@ -62,6 +66,10 @@ export const getPublicCarbet = cache(
roadAccessNote: true,
capacity: true,
nightlyPrice: true,
+ roadAccess: true,
+ electricity: true,
+ gsmAtCarbet: true,
+ gsmExitDistanceKm: true,
minStayNights: true,
maxStayNights: true,
minCapacity: true,
@@ -113,6 +121,10 @@ export const getPublicCarbet = cache(
roadAccessNote: carbet.roadAccessNote,
capacity: carbet.capacity,
nightlyPrice: carbet.nightlyPrice.toString(),
+ roadAccess: carbet.roadAccess,
+ electricity: carbet.electricity,
+ gsmAtCarbet: carbet.gsmAtCarbet,
+ gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null,
minStayNights: carbet.minStayNights,
maxStayNights: carbet.maxStayNights,
minCapacity: carbet.minCapacity,
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index b2cb041..cd53126 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -5,6 +5,8 @@ import {
AvailabilityBlockReason,
AvailabilityScope,
CarbetStatus,
+ Electricity,
+ RoadAccess,
} from "@/generated/prisma/enums";
import { getCarbetReviewStatsMany } from "@/lib/reviews-server";
@@ -13,11 +15,16 @@ export type CarbetSearchFilters = {
startDate?: Date;
endDate?: Date;
capacity?: number;
- // Filtre plugin access-type : si "river-only" exclu, on garde uniquement
- // ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe.
+ capacityMax?: number;
accessibility?: "road-only" | "all";
priceMax?: number;
amenities?: string[];
+ /** Niveaux d'accès route acceptés (multi). */
+ roadAccess?: RoadAccess[];
+ /** Niveaux d'électricité acceptés (multi). */
+ electricity?: Electricity[];
+ /** Distance max en km pour atteindre le réseau GSM. 0 = exige le réseau au carbet. */
+ gsmMaxKm?: number;
};
export type RawSearchParams = {
@@ -71,6 +78,45 @@ export function parseSearchFilters(
filters.accessibility = accessibility;
}
+ const capacityMaxRaw = pickString(searchParams.capacityMax);
+ if (capacityMaxRaw) {
+ const cmax = Number(capacityMaxRaw);
+ if (Number.isInteger(cmax) && cmax > 0 && cmax <= 100) filters.capacityMax = cmax;
+ }
+
+ const roadRaw = searchParams.roadAccess;
+ if (roadRaw) {
+ const arr = Array.isArray(roadRaw) ? roadRaw : [roadRaw];
+ const keys = arr
+ .flatMap((s) => s.split(","))
+ .map((s) => s.trim())
+ .filter((s): s is RoadAccess =>
+ s === RoadAccess.NONE || s === RoadAccess.DRY_SEASON_ONLY || s === RoadAccess.ALL_YEAR,
+ );
+ if (keys.length > 0) filters.roadAccess = Array.from(new Set(keys));
+ }
+
+ const elecRaw = searchParams.electricity;
+ if (elecRaw) {
+ const arr = Array.isArray(elecRaw) ? elecRaw : [elecRaw];
+ const keys = arr
+ .flatMap((s) => s.split(","))
+ .map((s) => s.trim())
+ .filter((s): s is Electricity =>
+ s === Electricity.NONE ||
+ s === Electricity.SOLAR ||
+ s === Electricity.GENERATOR_READY ||
+ s === Electricity.EDF,
+ );
+ if (keys.length > 0) filters.electricity = Array.from(new Set(keys));
+ }
+
+ const gsmMaxRaw = pickString(searchParams.gsmMaxKm);
+ if (gsmMaxRaw) {
+ const km = Number(gsmMaxRaw);
+ if (Number.isFinite(km) && km >= 0 && km <= 50) filters.gsmMaxKm = km;
+ }
+
const priceMaxRaw = pickString(searchParams.priceMax);
if (priceMaxRaw) {
const priceMax = Number(priceMaxRaw);
@@ -113,6 +159,10 @@ export type CarbetSearchResult = {
nightlyPrice: string;
latitude: number;
longitude: number;
+ roadAccess: RoadAccess | null;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
};
// Build the Prisma where-clause for a public carbet search. A carbet is only
@@ -127,8 +177,30 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput {
where.river = { contains: filters.river, mode: "insensitive" };
}
- if (filters.capacity) {
- where.capacity = { gte: filters.capacity };
+ if (filters.capacity || filters.capacityMax) {
+ where.capacity = {};
+ if (filters.capacity) where.capacity.gte = filters.capacity;
+ if (filters.capacityMax) where.capacity.lte = filters.capacityMax;
+ }
+
+ if (filters.roadAccess && filters.roadAccess.length > 0) {
+ where.roadAccess = { in: filters.roadAccess };
+ }
+
+ if (filters.electricity && filters.electricity.length > 0) {
+ where.electricity = { in: filters.electricity };
+ }
+
+ if (filters.gsmMaxKm !== undefined) {
+ if (filters.gsmMaxKm === 0) {
+ where.gsmAtCarbet = true;
+ } else {
+ where.OR = [
+ ...(where.OR ?? []),
+ { gsmAtCarbet: true },
+ { gsmExitDistanceKm: { lte: filters.gsmMaxKm } },
+ ];
+ }
}
if (filters.accessibility === "road-only") {
@@ -182,6 +254,10 @@ export async function searchCarbets(
maxStayNights: true,
minCapacity: true,
description: true,
+ roadAccess: true,
+ electricity: true,
+ gsmAtCarbet: true,
+ gsmExitDistanceKm: true,
nightlyPrice: true,
latitude: true,
longitude: true,
@@ -222,6 +298,10 @@ export async function searchCarbets(
nightlyPrice: carbet.nightlyPrice.toString(),
latitude: Number(carbet.latitude),
longitude: Number(carbet.longitude),
+ roadAccess: carbet.roadAccess,
+ electricity: carbet.electricity,
+ gsmAtCarbet: carbet.gsmAtCarbet,
+ gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null,
};
});
}
diff --git a/src/lib/search-profiles.ts b/src/lib/search-profiles.ts
new file mode 100644
index 0000000..cff37da
--- /dev/null
+++ b/src/lib/search-profiles.ts
@@ -0,0 +1,79 @@
+/**
+ * Profils de séjour prédéfinis — chips au-dessus des facettes.
+ * Chaque profil pose un set de query params qui pré-cochent les filtres.
+ */
+
+import { Electricity, RoadAccess } from "@/generated/prisma/enums";
+
+export type SearchProfile = {
+ id: string;
+ emoji: string;
+ label: string;
+ description: string;
+ params: Record;
+};
+
+export const SEARCH_PROFILES: SearchProfile[] = [
+ {
+ id: "deconnexion",
+ emoji: "🌿",
+ label: "Déconnexion totale",
+ description: "Zone blanche, pas d'électricité, accès pirogue, 2-4 personnes.",
+ params: {
+ roadAccess: RoadAccess.NONE,
+ electricity: `${Electricity.NONE},${Electricity.SOLAR}`,
+ capacityMax: "4",
+ },
+ },
+ {
+ id: "teletravail",
+ emoji: "💻",
+ label: "Télétravail nature",
+ description: "Route, EDF, 4G au carbet — bureau au bord du fleuve.",
+ params: {
+ roadAccess: RoadAccess.ALL_YEAR,
+ electricity: Electricity.EDF,
+ gsmMaxKm: "0",
+ },
+ },
+ {
+ id: "famille-weekend",
+ emoji: "🏝️",
+ label: "Famille week-end",
+ description: "Route toute saison, électricité, capacité 4-8.",
+ params: {
+ roadAccess: RoadAccess.ALL_YEAR,
+ electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`,
+ capacity: "4",
+ capacityMax: "8",
+ },
+ },
+ {
+ id: "astreinte",
+ emoji: "📞",
+ label: "Astreinte sereine",
+ description: "Réseau accessible (au max 1 km), EDF, route saison sèche min.",
+ params: {
+ gsmMaxKm: "1",
+ electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`,
+ roadAccess: `${RoadAccess.DRY_SEASON_ONLY},${RoadAccess.ALL_YEAR}`,
+ },
+ },
+ {
+ id: "aventure",
+ emoji: "🛶",
+ label: "Aventure expédition",
+ description: "Accès pirogue uniquement, petit groupe 2-4.",
+ params: {
+ roadAccess: RoadAccess.NONE,
+ capacityMax: "4",
+ },
+ },
+];
+
+export function buildProfileUrl(profileId: string): string {
+ const profile = SEARCH_PROFILES.find((p) => p.id === profileId);
+ if (!profile) return "/carbets";
+ const search = new URLSearchParams(profile.params);
+ return `/carbets?${search.toString()}`;
+}
From 4901bb950ebc826de43adeb50b0498ed0157a896 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 02:46:34 +0000
Subject: [PATCH 13/34] =?UTF-8?q?feat(forms):=204=20crit=C3=A8res=20op?=
=?UTF-8?q?=C3=A9rationnels=20dans=20formulaires=20admin=20+=20espace=20h?=
=?UTF-8?q?=C3=B4te?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/carbets/[id]/page.tsx | 4 +
.../admin/carbets/_components/CarbetForm.tsx | 61 +++++++++++++
src/app/admin/carbets/actions.ts | 16 +++-
.../espace-hote/carbets/[carbetId]/page.tsx | 8 ++
.../carbets/_components/carbet-form.tsx | 88 +++++++++++++++++++
src/app/espace-hote/carbets/actions.ts | 53 ++++++++++-
6 files changed, 228 insertions(+), 2 deletions(-)
diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx
index 7799bef..bf7a972 100644
--- a/src/app/admin/carbets/[id]/page.tsx
+++ b/src/app/admin/carbets/[id]/page.tsx
@@ -94,6 +94,10 @@ export default async function EditCarbetPage({ params }: PageProps) {
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,
diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx
index 260996b..4ddabe8 100644
--- a/src/app/admin/carbets/_components/CarbetForm.tsx
+++ b/src/app/admin/carbets/_components/CarbetForm.tsx
@@ -20,6 +20,10 @@ export type CarbetFormInitial = {
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;
@@ -189,6 +193,63 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
+ {/* Critères opérationnels */}
+
+
{/* Séjour & tarif */}
Séjour & tarif
diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts
index 9e2fbff..2004bd8 100644
--- a/src/app/admin/carbets/actions.ts
+++ b/src/app/admin/carbets/actions.ts
@@ -10,7 +10,9 @@ import { prisma } from "@/lib/prisma";
import {
AccessType,
CarbetStatus,
+ Electricity,
MediaType,
+ RoadAccess,
TransportMode,
UserRole,
} from "@/generated/prisma/enums";
@@ -29,6 +31,16 @@ const baseCarbetSchema = z.object({
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(),
@@ -53,9 +65,11 @@ function parseFromFormData(fd: FormData) {
if (typeof v === "string") obj[k] = v;
}
// Normalise les champs optionnels nullables
- ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach(
+ ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach(
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
);
+ // gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod)
+ if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no";
return obj;
}
diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx
index 2b8b069..39ae0f9 100644
--- a/src/app/espace-hote/carbets/[carbetId]/page.tsx
+++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx
@@ -32,6 +32,10 @@ export default async function EditCarbetPage({
embarkPoint: true,
pirogueDurationMin: true,
capacity: true,
+ roadAccess: true,
+ electricity: true,
+ gsmAtCarbet: true,
+ gsmExitDistanceKm: true,
status: true,
media: {
orderBy: { sortOrder: "asc" },
@@ -54,6 +58,10 @@ export default async function EditCarbetPage({
embarkPoint: carbet.embarkPoint,
pirogueDurationMin: String(carbet.pirogueDurationMin),
capacity: String(carbet.capacity),
+ roadAccess: carbet.roadAccess ?? "",
+ electricity: carbet.electricity ?? "",
+ gsmAtCarbet: carbet.gsmAtCarbet,
+ gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "",
status: carbet.status,
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
};
diff --git a/src/app/espace-hote/carbets/_components/carbet-form.tsx b/src/app/espace-hote/carbets/_components/carbet-form.tsx
index ac2d234..3a484c6 100644
--- a/src/app/espace-hote/carbets/_components/carbet-form.tsx
+++ b/src/app/espace-hote/carbets/_components/carbet-form.tsx
@@ -17,6 +17,10 @@ export type CarbetFormDefaults = {
embarkPoint: string;
pirogueDurationMin: string;
capacity: string;
+ roadAccess: string;
+ electricity: string;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: string;
status: CarbetStatus;
amenityKeys: string[];
};
@@ -216,6 +220,90 @@ export function CarbetForm({
+
+
+
+ Critères opérationnels
+
+
+ Les 4 dealbreakers d'un séjour en carbet. Ces critères apparaissent
+ en grand sur votre fiche et alimentent les filtres recherche.
+
+
+
+
+
+ 🛣️ Accès route
+
+
+ — non précisé —
+ 🛣️ Toute saison
+ 🟠 Saison sèche uniquement
+ 🛶 Pirogue uniquement
+
+
+
+
+
+ ⚡ Électricité
+
+
+ — non précisé —
+ ⚡ EDF / raccordé réseau
+ 🔌 Préinstall groupe électrogène
+ ☀️ Solaire
+ 🕯️ Aucune électricité
+
+
+
+
+
+ 📶 Réseau GSM au carbet
+
+
+ ✅ Oui, signal au carbet
+ ❌ Non, zone sans réseau
+
+
+
+
+
+ 📵 Distance pour atteindre le réseau (km)
+
+
+
+ Laissez vide si réseau au carbet
+
+
+
+
+
+
Commodités
diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts
index 8964376..ba29ac4 100644
--- a/src/app/espace-hote/carbets/actions.ts
+++ b/src/app/espace-hote/carbets/actions.ts
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
import { ensureUniqueCarbetSlug } from "@/lib/slug";
import { deleteObject } from "@/lib/storage";
import { Prisma } from "@/generated/prisma/client";
-import { CarbetStatus } from "@/generated/prisma/enums";
+import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
@@ -22,10 +22,26 @@ type ParsedCarbet = {
embarkPoint: string;
pirogueDurationMin: number;
capacity: number;
+ roadAccess: RoadAccess | null;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
status: CarbetStatus;
amenities: string[];
};
+function isRoadAccess(v: string): v is RoadAccess {
+ return v === RoadAccess.NONE || v === RoadAccess.DRY_SEASON_ONLY || v === RoadAccess.ALL_YEAR;
+}
+function isElectricity(v: string): v is Electricity {
+ return (
+ v === Electricity.NONE ||
+ v === Electricity.SOLAR ||
+ v === Electricity.GENERATOR_READY ||
+ v === Electricity.EDF
+ );
+}
+
function isCarbetStatus(value: string): value is CarbetStatus {
return (Object.values(CarbetStatus) as string[]).includes(value);
}
@@ -107,6 +123,29 @@ function parseCarbetForm(formData: FormData): {
const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT;
+ // Critères opérationnels
+ const roadAccessRaw = String(formData.get("roadAccess") ?? "").trim();
+ const roadAccess = isRoadAccess(roadAccessRaw) ? roadAccessRaw : null;
+
+ const electricityRaw = String(formData.get("electricity") ?? "").trim();
+ const electricity = isElectricity(electricityRaw) ? electricityRaw : null;
+
+ const gsmAtCarbet = String(formData.get("gsmAtCarbet") ?? "no") === "yes";
+
+ const gsmExitRaw = String(formData.get("gsmExitDistanceKm") ?? "").trim();
+ let gsmExitDistanceKm: number | null = null;
+ if (gsmExitRaw) {
+ const n = Number(gsmExitRaw);
+ if (Number.isFinite(n) && n >= 0 && n <= 50) {
+ gsmExitDistanceKm = n;
+ } else {
+ errors.gsmExitDistanceKm = "Distance invalide (0 à 50 km).";
+ }
+ }
+
+ // Cohérence : si GSM au carbet, on ignore la distance
+ const finalGsmExitDistanceKm = gsmAtCarbet ? null : gsmExitDistanceKm;
+
return {
data: {
title,
@@ -117,6 +156,10 @@ function parseCarbetForm(formData: FormData): {
embarkPoint,
pirogueDurationMin,
capacity,
+ roadAccess,
+ electricity,
+ gsmAtCarbet,
+ gsmExitDistanceKm: finalGsmExitDistanceKm,
status,
amenities,
},
@@ -183,6 +226,10 @@ export async function createCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
+ roadAccess: data.roadAccess,
+ electricity: data.electricity,
+ gsmAtCarbet: data.gsmAtCarbet,
+ gsmExitDistanceKm: data.gsmExitDistanceKm,
status: CarbetStatus.DRAFT,
},
select: { id: true },
@@ -239,6 +286,10 @@ export async function updateCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
+ roadAccess: data.roadAccess,
+ electricity: data.electricity,
+ gsmAtCarbet: data.gsmAtCarbet,
+ gsmExitDistanceKm: data.gsmExitDistanceKm,
status: data.status,
},
});
From e2f3f070faed517c8b63594a5d64817961c69945 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 03:26:04 +0000
Subject: [PATCH 14/34] =?UTF-8?q?feat(rental):=20Sprint=20A=20=E2=80=94=20?=
=?UTF-8?q?mod=C3=A8le=20Prisma=20+=20admin=20CRUD=20+=20seed=2013=20items?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../migration.sql | 112 +++++++++++++
prisma/schema.prisma | 143 +++++++++++++++-
.../[id]/_components/ItemInlineActions.tsx | 86 ++++++++++
src/app/admin/rental-items/[id]/page.tsx | 83 ++++++++++
.../rental-items/_components/ItemForm.tsx | 141 ++++++++++++++++
src/app/admin/rental-items/actions.ts | 129 +++++++++++++++
src/app/admin/rental-items/new/page.tsx | 31 ++++
src/app/admin/rental-items/page.tsx | 152 ++++++++++++++++++
.../_components/ProviderInlineActions.tsx | 120 ++++++++++++++
src/app/admin/rental-providers/[id]/page.tsx | 136 ++++++++++++++++
.../_components/ProviderForm.tsx | 132 +++++++++++++++
src/app/admin/rental-providers/actions.ts | 150 +++++++++++++++++
src/app/admin/rental-providers/new/page.tsx | 21 +++
src/app/admin/rental-providers/page.tsx | 149 +++++++++++++++++
src/app/admin/rentals/page.tsx | 141 ++++++++++++++++
src/components/admin/Sidebar.tsx | 3 +
src/lib/admin/rental-bookings.ts | 60 +++++++
src/lib/admin/rental-items.ts | 111 +++++++++++++
src/lib/admin/rental-providers.ts | 106 ++++++++++++
19 files changed, 2000 insertions(+), 6 deletions(-)
create mode 100644 prisma/migrations/20260603000000_rental_marketplace/migration.sql
create mode 100644 src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
create mode 100644 src/app/admin/rental-items/[id]/page.tsx
create mode 100644 src/app/admin/rental-items/_components/ItemForm.tsx
create mode 100644 src/app/admin/rental-items/actions.ts
create mode 100644 src/app/admin/rental-items/new/page.tsx
create mode 100644 src/app/admin/rental-items/page.tsx
create mode 100644 src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
create mode 100644 src/app/admin/rental-providers/[id]/page.tsx
create mode 100644 src/app/admin/rental-providers/_components/ProviderForm.tsx
create mode 100644 src/app/admin/rental-providers/actions.ts
create mode 100644 src/app/admin/rental-providers/new/page.tsx
create mode 100644 src/app/admin/rental-providers/page.tsx
create mode 100644 src/app/admin/rentals/page.tsx
create mode 100644 src/lib/admin/rental-bookings.ts
create mode 100644 src/lib/admin/rental-items.ts
create mode 100644 src/lib/admin/rental-providers.ts
diff --git a/prisma/migrations/20260603000000_rental_marketplace/migration.sql b/prisma/migrations/20260603000000_rental_marketplace/migration.sql
new file mode 100644
index 0000000..65b4eb1
--- /dev/null
+++ b/prisma/migrations/20260603000000_rental_marketplace/migration.sql
@@ -0,0 +1,112 @@
+-- UserRole : ajouter RENTAL_PROVIDER
+ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
+
+-- Enums dédiés
+CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
+CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
+
+-- RentalProvider
+CREATE TABLE "RentalProvider" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "isSystemD" BOOLEAN NOT NULL DEFAULT false,
+ "managedByUserId" TEXT,
+ "contactEmail" TEXT,
+ "contactPhone" TEXT,
+ "rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "description" TEXT,
+ "commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "approved" BOOLEAN NOT NULL DEFAULT false,
+ "approvedAt" TIMESTAMP(3),
+ "approvedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
+CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
+
+-- RentalItem
+CREATE TABLE "RentalItem" (
+ "id" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "category" "RentalCategory" NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "imageUrl" TEXT,
+ "pricePerDay" DECIMAL(8,2) NOT NULL,
+ "pricePerWeek" DECIMAL(8,2),
+ "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "totalQty" INTEGER NOT NULL DEFAULT 1,
+ "withMotor" BOOLEAN NOT NULL DEFAULT false,
+ "fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
+ "requiresLicense" BOOLEAN NOT NULL DEFAULT false,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
+CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
+
+-- RentalItemAvailability
+CREATE TABLE "RentalItemAvailability" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "startDate" TIMESTAMP(3) NOT NULL,
+ "endDate" TIMESTAMP(3) NOT NULL,
+ "qty" INTEGER NOT NULL,
+ "reason" TEXT NOT NULL,
+ "rentalBookingId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
+CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
+
+-- RentalBooking
+CREATE TABLE "RentalBooking" (
+ "id" TEXT NOT NULL,
+ "bookingId" TEXT,
+ "tenantId" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "startDate" TIMESTAMP(3) NOT NULL,
+ "endDate" TIMESTAMP(3) NOT NULL,
+ "status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
+ "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
+ "itemsTotal" DECIMAL(10,2) NOT NULL,
+ "depositTotal" DECIMAL(10,2) NOT NULL,
+ "commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
+ "amount" DECIMAL(10,2) NOT NULL,
+ "currency" TEXT NOT NULL DEFAULT 'EUR',
+ "stripeSessionId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
+CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
+CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
+CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
+
+-- RentalLine
+CREATE TABLE "RentalLine" (
+ "id" TEXT NOT NULL,
+ "rentalBookingId" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "qty" INTEGER NOT NULL,
+ "pricePerDay" DECIMAL(8,2) NOT NULL,
+ "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "lineTotal" DECIMAL(10,2) NOT NULL,
+ CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0636340..7580413 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -13,6 +13,7 @@ enum UserRole {
CE_MEMBER
TOURIST
ADMIN
+ RENTAL_PROVIDER
}
enum CarbetStatus {
@@ -97,11 +98,13 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
- carbets Carbet[] @relation("CarbetOwner")
- bookings Booking[] @relation("BookingTenant")
- reviews Review[] @relation("ReviewAuthor")
- subscriptions Subscription[]
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
+ carbets Carbet[] @relation("CarbetOwner")
+ bookings Booking[] @relation("BookingTenant")
+ reviews Review[] @relation("ReviewAuthor")
+ subscriptions Subscription[]
+ rentalProviders RentalProvider[]
+ rentalBookings RentalBooking[] @relation("RentalBookingTenant")
@@index([organizationId])
@@index([role])
@@ -249,7 +252,8 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
- review Review?
+ review Review?
+ rentalBookings RentalBooking[]
@@index([carbetId])
@@index([tenantId])
@@ -399,3 +403,130 @@ enum Electricity {
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])
+}
diff --git a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
new file mode 100644
index 0000000..8a6a00f
--- /dev/null
+++ b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ active: boolean;
+ toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(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 (
+
+
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {confirmDelete ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
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
+
+ )}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx
new file mode 100644
index 0000000..8f4dd4a
--- /dev/null
+++ b/src/app/admin/rental-items/[id]/page.tsx
@@ -0,0 +1,83 @@
+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 (
+
+ );
+}
diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx
new file mode 100644
index 0000000..523dabc
--- /dev/null
+++ b/src/app/admin/rental-items/_components/ItemForm.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+import { RentalCategory } from "@/generated/prisma/enums";
+
+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;
+};
+
+const CATEGORIES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(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 (
+
+ );
+}
diff --git a/src/app/admin/rental-items/actions.ts b/src/app/admin/rental-items/actions.ts
new file mode 100644
index 0000000..c5eaad2
--- /dev/null
+++ b/src/app/admin/rental-items/actions.ts
@@ -0,0 +1,129 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { RentalCategory, UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const itemSchema = z.object({
+ providerId: z.string().min(1),
+ category: z.enum([
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+ ]),
+ name: z.string().trim().min(2).max(200),
+ description: z.string().trim().max(5000).nullable().optional(),
+ imageUrl: z.string().trim().url().max(500).nullable().optional(),
+ pricePerDay: z.coerce.number().min(0).max(10000),
+ pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
+ deposit: z.coerce.number().min(0).max(10000),
+ totalQty: z.coerce.number().int().min(1).max(1000),
+ withMotor: z.boolean(),
+ fuelIncluded: z.boolean(),
+ requiresLicense: z.boolean(),
+ active: z.boolean(),
+});
+
+function parseFD(fd: FormData) {
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
+ category: ((fd.get("category") as string | null) ?? "").trim(),
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ description: get("description"),
+ imageUrl: get("imageUrl"),
+ pricePerDay: fd.get("pricePerDay"),
+ pricePerWeek: get("pricePerWeek"),
+ deposit: fd.get("deposit") ?? "0",
+ totalQty: fd.get("totalQty") ?? "1",
+ withMotor: fd.get("withMotor") === "on",
+ fuelIncluded: fd.get("fuelIncluded") === "on",
+ requiresLicense: fd.get("requiresLicense") === "on",
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createRentalItemAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = itemSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.rentalItem.create({ data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "create",
+ target: created.id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: created.name, providerId: created.providerId },
+ });
+ revalidatePath("/admin/rental-items");
+ redirect(`/admin/rental-items/${created.id}`);
+}
+
+export async function updateRentalItemAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = itemSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.rentalItem.update({ where: { id }, data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/admin/rental-items");
+ revalidatePath(`/admin/rental-items/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleRentalItemActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalItem.update({ where: { id }, data: { active } });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "active.update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { active },
+ });
+ revalidatePath("/admin/rental-items");
+ revalidatePath(`/admin/rental-items/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteRentalItemAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
+ if (linesCount > 0) {
+ return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
+ }
+ await prisma.rentalItem.delete({ where: { id } });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "delete",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-items");
+ redirect("/admin/rental-items");
+}
diff --git a/src/app/admin/rental-items/new/page.tsx b/src/app/admin/rental-items/new/page.tsx
new file mode 100644
index 0000000..fec17d0
--- /dev/null
+++ b/src/app/admin/rental-items/new/page.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link";
+import { ItemForm } from "../_components/ItemForm";
+import { createRentalItemAction } from "../actions";
+import { listProvidersForSelect } from "@/lib/admin/rental-items";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { searchParams: Promise<{ providerId?: string }> };
+
+export default async function NewRentalItemPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const providers = await listProvidersForSelect();
+ return (
+
+
+
+ ← Tous les items
+
+ Nouvel item locable
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-items/page.tsx b/src/app/admin/rental-items/page.tsx
new file mode 100644
index 0000000..d67a556
--- /dev/null
+++ b/src/app/admin/rental-items/page.tsx
@@ -0,0 +1,152 @@
+import Link from "next/link";
+import { RentalCategory } from "@/generated/prisma/enums";
+import {
+ RENTAL_CATEGORY_LABEL,
+ isRentalCategory,
+ listProvidersForSelect,
+ listRentalItemsAdmin,
+} from "@/lib/admin/rental-items";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ category?: string;
+ providerId?: string;
+ active?: string;
+ }>;
+};
+
+export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
+ providerId: sp.providerId || undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ };
+ const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Toutes catégories
+ {Object.values(RentalCategory).map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+ Tous prestataires
+ {providers.map((p) => (
+ {p.name}
+ ))}
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Filtrer
+
+ {(filters.q || filters.category || filters.providerId || filters.active) ? (
+ Réinit.
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Catégorie
+ Prestataire
+ € / jour
+ Stock
+ Caution
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun item.
+
+
+ ) : null}
+ {rows.map((i) => (
+
+
+
+ {i.name}
+
+
+ {i.withMotor ? "⚙️ moteur · " : ""}
+ {i.requiresLicense ? "🪪 permis · " : ""}
+ {i.fuelIncluded ? "⛽ essence · " : ""}
+
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+
+
+ {i.providerName}
+
+ {i.providerIsSystemD ? (
+
+ SD
+
+ ) : null}
+
+ {Number(i.pricePerDay).toFixed(0)}
+ {i.totalQty}
+ {Number(i.deposit).toFixed(0)}
+
+ {dateFmt.format(i.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx b/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
new file mode 100644
index 0000000..1839fae
--- /dev/null
+++ b/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ approved: boolean;
+ active: boolean;
+ itemsCount: number;
+ approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ProviderInlineActions({
+ approved,
+ active,
+ itemsCount,
+ approveAction,
+ toggleActiveAction,
+ deleteAction,
+}: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(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 (
+
+
+ {!approved ? (
+
+ ✓ Approuver
+
+ ) : null}
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {itemsCount === 0 ? (
+ confirmDelete ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
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
+
+ )
+ ) : (
+
+ {itemsCount} item(s) — supprimez-les d'abord
+
+ )}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/rental-providers/[id]/page.tsx b/src/app/admin/rental-providers/[id]/page.tsx
new file mode 100644
index 0000000..5358bd1
--- /dev/null
+++ b/src/app/admin/rental-providers/[id]/page.tsx
@@ -0,0 +1,136 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+
+import { ProviderForm } from "../_components/ProviderForm";
+import { ProviderInlineActions } from "./_components/ProviderInlineActions";
+import {
+ approveRentalProviderAction,
+ deleteRentalProviderAction,
+ toggleRentalProviderActiveAction,
+ updateRentalProviderAction,
+} from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditRentalProviderPage({ params }: PageProps) {
+ const { id } = await params;
+ const p = await getRentalProviderForAdmin(id);
+ if (!p) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateRentalProviderAction(id, fd);
+ };
+ const approveThis = async () => {
+ "use server";
+ return await approveRentalProviderAction(id);
+ };
+ const toggleActiveThis = async (active: boolean) => {
+ "use server";
+ return await toggleRentalProviderActiveAction(id, active);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteRentalProviderAction(id);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Items ({p.items.length})
+
+ Voir tous les items
+
+
+ {p.items.length === 0 ? (
+
+ Pas encore d'item.{" "}
+
+ Créer un premier item
+
+
+ ) : (
+
+ {p.items.map((i) => (
+
+
+ {i.name}
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+
+
+
+ {Number(i.pricePerDay).toFixed(0)} €/j
+ qty {i.totalQty}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/_components/ProviderForm.tsx b/src/app/admin/rental-providers/_components/ProviderForm.tsx
new file mode 100644
index 0000000..baf84a9
--- /dev/null
+++ b/src/app/admin/rental-providers/_components/ProviderForm.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
+
+type Props = {
+ initial?: {
+ name?: string;
+ isSystemD?: boolean;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
+ rivers?: string[];
+ description?: string | null;
+ commissionPct?: number | string;
+ active?: boolean;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(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 (
+
+
+
+
+
+
+
+
+
+ Fournisseur officiel System D (0 % commission)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actif
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/actions.ts b/src/app/admin/rental-providers/actions.ts
new file mode 100644
index 0000000..3561471
--- /dev/null
+++ b/src/app/admin/rental-providers/actions.ts
@@ -0,0 +1,150 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const providerSchema = z.object({
+ name: z.string().trim().min(2).max(200),
+ isSystemD: z.boolean(),
+ managedByUserId: z.string().nullable().optional(),
+ contactEmail: z.string().trim().email().max(200).nullable().optional(),
+ contactPhone: z.string().trim().max(50).nullable().optional(),
+ rivers: z.array(z.string().trim().min(1).max(80)).max(20),
+ description: z.string().trim().max(5000).nullable().optional(),
+ commissionPct: z.coerce.number().min(0).max(50),
+ active: z.boolean(),
+});
+
+function parseFD(fd: FormData) {
+ const riversRaw = (fd.get("rivers") as string | null) ?? "";
+ const rivers = riversRaw
+ .split(/[,;\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ isSystemD: fd.get("isSystemD") === "on",
+ managedByUserId: get("managedByUserId"),
+ contactEmail: get("contactEmail"),
+ contactPhone: get("contactPhone"),
+ rivers,
+ description: get("description"),
+ commissionPct: fd.get("commissionPct"),
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createRentalProviderAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.rentalProvider.create({
+ data: {
+ ...parsed.data,
+ approved: true, // créé par admin → approuvé d'office
+ approvedAt: new Date(),
+ approvedBy: session?.user?.email ?? null,
+ },
+ });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "create",
+ target: created.id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: created.name, isSystemD: created.isSystemD },
+ });
+ revalidatePath("/admin/rental-providers");
+ redirect(`/admin/rental-providers/${created.id}`);
+}
+
+export async function updateRentalProviderAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function approveRentalProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalProvider.update({
+ where: { id },
+ data: {
+ approved: true,
+ approvedAt: new Date(),
+ approvedBy: session?.user?.email ?? null,
+ },
+ });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "approve",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalProvider.update({ where: { id }, data: { active } });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "active.update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { active },
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteRentalProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
+ if (itemsCount > 0) {
+ return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
+ }
+ await prisma.rentalProvider.delete({ where: { id } });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "delete",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-providers");
+ redirect("/admin/rental-providers");
+}
diff --git a/src/app/admin/rental-providers/new/page.tsx b/src/app/admin/rental-providers/new/page.tsx
new file mode 100644
index 0000000..c836fea
--- /dev/null
+++ b/src/app/admin/rental-providers/new/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { ProviderForm } from "../_components/ProviderForm";
+import { createRentalProviderAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default function NewRentalProviderPage() {
+ return (
+
+
+
+ ← Tous les prestataires
+
+ Nouveau prestataire location
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/page.tsx b/src/app/admin/rental-providers/page.tsx
new file mode 100644
index 0000000..d2548e3
--- /dev/null
+++ b/src/app/admin/rental-providers/page.tsx
@@ -0,0 +1,149 @@
+import Link from "next/link";
+import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ approved?: string;
+ active?: string;
+ river?: string;
+ }>;
+};
+
+export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ river: sp.river || undefined,
+ };
+ const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous statuts approbation
+ Approuvés
+ En attente
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+ Filtrer
+
+ {(filters.q || filters.approved || filters.active || filters.river) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Fleuves
+ Items
+ Comm.
+ Approbation
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun prestataire ne correspond aux filtres.
+
+
+ ) : null}
+ {rows.map((p) => (
+
+
+
+ {p.name}
+
+ {p.isSystemD ? (
+
+ System D
+
+ ) : null}
+ {p.contactEmail ?? "—"}
+
+
+ {p.rivers.length === 0 ? — : p.rivers.join(", ")}
+
+ {p.itemsCount}
+ {Number(p.commissionPct).toFixed(1)}%
+
+ {p.approved ? (
+
+ Approuvé
+
+ ) : (
+
+ En attente
+
+ )}
+
+
+ {dateFmt.format(p.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rentals/page.tsx b/src/app/admin/rentals/page.tsx
new file mode 100644
index 0000000..34ddb12
--- /dev/null
+++ b/src/app/admin/rentals/page.tsx
@@ -0,0 +1,141 @@
+import Link from "next/link";
+import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
+import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ status?: string;
+ paymentStatus?: string;
+ providerId?: string;
+ }>;
+};
+
+const RENTAL_STATUS_VALUES = new Set([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+const PAYMENT_VALUES = new Set([
+ 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 (
+
+
+
+
+
+
+ Tous statuts
+ {Object.values(RentalBookingStatus).map((s) => (
+ {RENTAL_STATUS_LABEL[s]}
+ ))}
+
+
+ Tous paiements
+ {Object.values(PaymentStatus).map((s) => (
+ {s}
+ ))}
+
+
+ Filtrer
+
+ {(filters.q || filters.status || filters.paymentStatus) ? (
+ Réinit.
+ ) : null}
+
+
+
+
+
+
+ ID
+ Locataire
+ Prestataire
+ Items
+ Période
+ Montant
+ Statut
+ Paiement
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucune réservation matériel.
+
+
+ ) : null}
+ {rows.map((r) => (
+
+ {r.id.slice(0, 10)}…
+
+ {r.tenant.firstName} {r.tenant.lastName}
+ {r.tenant.email}
+
+
+
+ {r.provider.name}
+
+ {r.provider.isSystemD ? SD : null}
+
+
+ {r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
+
+ {r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
+
+
+
+ {dateFmt.format(r.startDate)} → {dateFmt.format(r.endDate)}
+
+
+ {Number(r.amount).toFixed(2)} {r.currency}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx
index e10428c..ad30de2 100644
--- a/src/components/admin/Sidebar.tsx
+++ b/src/components/admin/Sidebar.tsx
@@ -115,6 +115,8 @@ const GROUPS: NavGroup[] = [
items: [
{ href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets },
{ href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue },
+ { href: "/admin/rental-providers", label: "Prestataires matériel", icon: ICONS.pirogue },
+ { href: "/admin/rental-items", label: "Items locables", icon: ICONS.media },
{ href: "/admin/media", label: "Médias", icon: ICONS.media },
],
},
@@ -122,6 +124,7 @@ const GROUPS: NavGroup[] = [
label: "Activité",
items: [
{ href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
+ { href: "/admin/rentals", label: "Locations matériel", icon: ICONS.bookings },
{ href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
],
},
diff --git a/src/lib/admin/rental-bookings.ts b/src/lib/admin/rental-bookings.ts
new file mode 100644
index 0000000..21b35e5
--- /dev/null
+++ b/src/lib/admin/rental-bookings.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalBookingStatus, PaymentStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type AdminRentalBookingFilters = {
+ q?: string;
+ status?: RentalBookingStatus;
+ paymentStatus?: PaymentStatus;
+ providerId?: string;
+};
+
+export const RENTAL_STATUS_LABEL: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ HANDED_OVER: "Remis client",
+ RETURNED: "Retourné",
+ CANCELLED: "Annulée",
+};
+
+export async function listRentalBookingsAdmin(
+ filters: AdminRentalBookingFilters = {},
+) {
+ const where: Prisma.RentalBookingWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { id: { contains: filters.q, mode: "insensitive" } },
+ { tenant: { email: { contains: filters.q, mode: "insensitive" } } },
+ { provider: { name: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ if (filters.status) where.status = filters.status;
+ if (filters.paymentStatus) where.paymentStatus = filters.paymentStatus;
+ if (filters.providerId) where.providerId = filters.providerId;
+
+ return prisma.rentalBooking.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 200,
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true } },
+ provider: { select: { id: true, name: true, isSystemD: true } },
+ booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
+ lines: { include: { item: { select: { name: true, category: true } } } },
+ },
+ });
+}
+
+export async function getRentalBookingForAdmin(id: string) {
+ return prisma.rentalBooking.findUnique({
+ where: { id },
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
+ provider: { select: { id: true, name: true, isSystemD: true, contactEmail: true, contactPhone: true } },
+ booking: { select: { id: true, carbet: { select: { id: true, title: true, slug: true } } } },
+ lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
+ },
+ });
+}
diff --git a/src/lib/admin/rental-items.ts b/src/lib/admin/rental-items.ts
new file mode 100644
index 0000000..d5c1bb0
--- /dev/null
+++ b/src/lib/admin/rental-items.ts
@@ -0,0 +1,111 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalCategory } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export const RENTAL_CATEGORY_LABEL: Record = {
+ SLEEP: "💤 Couchage",
+ NAVIGATION: "🛶 Navigation",
+ FISHING: "🎣 Pêche",
+ COOKING: "🍳 Cuisine",
+ SAFETY: "🦺 Sécurité",
+};
+
+export type AdminRentalItemFilters = {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ active?: "yes" | "no";
+};
+
+export type AdminRentalItemListItem = {
+ id: string;
+ name: string;
+ category: RentalCategory;
+ providerId: string;
+ providerName: string;
+ providerIsSystemD: boolean;
+ pricePerDay: string;
+ pricePerWeek: string | null;
+ deposit: string;
+ totalQty: number;
+ withMotor: boolean;
+ fuelIncluded: boolean;
+ requiresLicense: boolean;
+ active: boolean;
+ imageUrl: string | null;
+ updatedAt: Date;
+};
+
+const CATEGORY_VALUES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function isRentalCategory(v: string): v is RentalCategory {
+ return (CATEGORY_VALUES as string[]).includes(v);
+}
+
+export async function listRentalItemsAdmin(
+ filters: AdminRentalItemFilters = {},
+): Promise {
+ const where: Prisma.RentalItemWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.category) where.category = filters.category;
+ if (filters.providerId) where.providerId = filters.providerId;
+ if (filters.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+
+ const rows = await prisma.rentalItem.findMany({
+ where,
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ take: 300,
+ include: {
+ provider: { select: { name: true, isSystemD: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ category: r.category,
+ providerId: r.providerId,
+ providerName: r.provider.name,
+ providerIsSystemD: r.provider.isSystemD,
+ pricePerDay: r.pricePerDay.toString(),
+ pricePerWeek: r.pricePerWeek?.toString() ?? null,
+ deposit: r.deposit.toString(),
+ totalQty: r.totalQty,
+ withMotor: r.withMotor,
+ fuelIncluded: r.fuelIncluded,
+ requiresLicense: r.requiresLicense,
+ active: r.active,
+ imageUrl: r.imageUrl,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function getRentalItemForAdmin(id: string) {
+ return prisma.rentalItem.findUnique({
+ where: { id },
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true } },
+ },
+ });
+}
+
+export async function listProvidersForSelect() {
+ return prisma.rentalProvider.findMany({
+ where: { active: true },
+ orderBy: [{ isSystemD: "desc" }, { name: "asc" }],
+ select: { id: true, name: true, isSystemD: true, approved: true },
+ });
+}
diff --git a/src/lib/admin/rental-providers.ts b/src/lib/admin/rental-providers.ts
new file mode 100644
index 0000000..b1e4d97
--- /dev/null
+++ b/src/lib/admin/rental-providers.ts
@@ -0,0 +1,106 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AdminRentalProviderFilters = {
+ q?: string;
+ approved?: "yes" | "no";
+ active?: "yes" | "no";
+ river?: string;
+};
+
+export type AdminRentalProviderListItem = {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ rivers: string[];
+ commissionPct: string;
+ active: boolean;
+ approved: boolean;
+ itemsCount: number;
+ updatedAt: Date;
+};
+
+export async function listRentalProvidersAdmin(
+ filters: AdminRentalProviderFilters = {},
+): Promise {
+ const where: Prisma.RentalProviderWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { contactEmail: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.approved === "yes") where.approved = true;
+ if (filters.approved === "no") where.approved = false;
+ if (filters.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+ if (filters.river) where.rivers = { has: filters.river };
+
+ const rows = await prisma.rentalProvider.findMany({
+ where,
+ orderBy: [{ approved: "asc" }, { isSystemD: "desc" }, { name: "asc" }],
+ take: 200,
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ contactEmail: true,
+ contactPhone: true,
+ rivers: true,
+ commissionPct: true,
+ active: true,
+ approved: true,
+ updatedAt: true,
+ _count: { select: { items: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ isSystemD: r.isSystemD,
+ contactEmail: r.contactEmail,
+ contactPhone: r.contactPhone,
+ rivers: r.rivers,
+ commissionPct: r.commissionPct.toString(),
+ active: r.active,
+ approved: r.approved,
+ itemsCount: r._count.items,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function getRentalProviderForAdmin(id: string) {
+ return prisma.rentalProvider.findUnique({
+ where: { id },
+ include: {
+ manager: { select: { id: true, firstName: true, lastName: true, email: true } },
+ items: {
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ select: {
+ id: true,
+ name: true,
+ category: true,
+ pricePerDay: true,
+ totalQty: true,
+ active: true,
+ },
+ },
+ _count: { select: { items: true, rentalBookings: true } },
+ },
+ });
+}
+
+export async function listRentalProviderRivers(): Promise {
+ const rows = await prisma.rentalProvider.findMany({
+ where: { active: true, approved: true },
+ select: { rivers: true },
+ });
+ const set = new Set();
+ for (const r of rows) for (const x of r.rivers) set.add(x);
+ return Array.from(set).sort();
+}
From 90cc7a94af423e5f75fdd6cd39764c0758bcf779 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 03:31:22 +0000
Subject: [PATCH 15/34] fix(rental): extract category labels en fichier neutre
(importable client)
---
.../rental-items/_components/ItemForm.tsx | 13 ++----------
src/lib/admin/rental-items.ts | 20 +-----------------
src/lib/rental-category-labels.ts | 21 +++++++++++++++++++
3 files changed, 24 insertions(+), 30 deletions(-)
create mode 100644 src/lib/rental-category-labels.ts
diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx
index 523dabc..27ad4b2 100644
--- a/src/app/admin/rental-items/_components/ItemForm.tsx
+++ b/src/app/admin/rental-items/_components/ItemForm.tsx
@@ -2,8 +2,7 @@
import { useState, useTransition } from "react";
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
-import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
-import { RentalCategory } from "@/generated/prisma/enums";
+import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
type Props = {
providers: { id: string; name: string; isSystemD: boolean }[];
@@ -26,14 +25,6 @@ type Props = {
submitLabel?: string;
};
-const CATEGORIES: RentalCategory[] = [
- RentalCategory.SLEEP,
- RentalCategory.NAVIGATION,
- RentalCategory.FISHING,
- RentalCategory.COOKING,
- RentalCategory.SAFETY,
-];
-
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState(null);
@@ -66,7 +57,7 @@ export function ItemForm({ providers, initial = {}, action, submitLabel = "Enreg
— sélectionner —
- {CATEGORIES.map((c) => (
+ {RENTAL_CATEGORIES.map((c) => (
{RENTAL_CATEGORY_LABEL[c]}
))}
diff --git a/src/lib/admin/rental-items.ts b/src/lib/admin/rental-items.ts
index d5c1bb0..01dd655 100644
--- a/src/lib/admin/rental-items.ts
+++ b/src/lib/admin/rental-items.ts
@@ -4,13 +4,7 @@ import { Prisma } from "@/generated/prisma/client";
import { RentalCategory } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
-export const RENTAL_CATEGORY_LABEL: Record = {
- SLEEP: "💤 Couchage",
- NAVIGATION: "🛶 Navigation",
- FISHING: "🎣 Pêche",
- COOKING: "🍳 Cuisine",
- SAFETY: "🦺 Sécurité",
-};
+export { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES, isRentalCategory } from "@/lib/rental-category-labels";
export type AdminRentalItemFilters = {
q?: string;
@@ -38,18 +32,6 @@ export type AdminRentalItemListItem = {
updatedAt: Date;
};
-const CATEGORY_VALUES: RentalCategory[] = [
- RentalCategory.SLEEP,
- RentalCategory.NAVIGATION,
- RentalCategory.FISHING,
- RentalCategory.COOKING,
- RentalCategory.SAFETY,
-];
-
-export function isRentalCategory(v: string): v is RentalCategory {
- return (CATEGORY_VALUES as string[]).includes(v);
-}
-
export async function listRentalItemsAdmin(
filters: AdminRentalItemFilters = {},
): Promise {
diff --git a/src/lib/rental-category-labels.ts b/src/lib/rental-category-labels.ts
new file mode 100644
index 0000000..63f14a1
--- /dev/null
+++ b/src/lib/rental-category-labels.ts
@@ -0,0 +1,21 @@
+import { RentalCategory } from "@/generated/prisma/enums";
+
+export const RENTAL_CATEGORY_LABEL: Record = {
+ SLEEP: "💤 Couchage",
+ NAVIGATION: "🛶 Navigation",
+ FISHING: "🎣 Pêche",
+ COOKING: "🍳 Cuisine",
+ SAFETY: "🦺 Sécurité",
+};
+
+export const RENTAL_CATEGORIES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function isRentalCategory(v: string): v is RentalCategory {
+ return (RENTAL_CATEGORIES as string[]).includes(v);
+}
From f31fb8a32cc41da6d06d94da0ac4f01280217d53 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 07:49:43 +0000
Subject: [PATCH 16/34] =?UTF-8?q?feat(rental):=20Sprint=20B=20=E2=80=94=20?=
=?UTF-8?q?catalogue=20public=20/materiel=20+=20d=C3=A9tail=20item=20+=20d?=
=?UTF-8?q?ispo=20+=20nav?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rentals/items/[id]/availability/route.ts | 31 +++
.../_components/AvailabilityPreview.tsx | 56 ++++++
src/app/materiel/[itemId]/page.tsx | 159 +++++++++++++++
.../materiel/_components/rental-filters.tsx | 100 ++++++++++
.../materiel/_components/rental-item-card.tsx | 76 ++++++++
src/app/materiel/page.tsx | 121 ++++++++++++
src/components/SiteHeader.tsx | 4 +-
src/lib/rentals-public.ts | 181 ++++++++++++++++++
8 files changed, 726 insertions(+), 2 deletions(-)
create mode 100644 src/app/api/rentals/items/[id]/availability/route.ts
create mode 100644 src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx
create mode 100644 src/app/materiel/[itemId]/page.tsx
create mode 100644 src/app/materiel/_components/rental-filters.tsx
create mode 100644 src/app/materiel/_components/rental-item-card.tsx
create mode 100644 src/app/materiel/page.tsx
create mode 100644 src/lib/rentals-public.ts
diff --git a/src/app/api/rentals/items/[id]/availability/route.ts b/src/app/api/rentals/items/[id]/availability/route.ts
new file mode 100644
index 0000000..dc3b8b2
--- /dev/null
+++ b/src/app/api/rentals/items/[id]/availability/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { getItemAvailability } from "@/lib/rentals-public";
+import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking";
+
+export const runtime = "nodejs";
+
+export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
+ const { id } = await ctx.params;
+ const from = parseIsoDate(req.nextUrl.searchParams.get("from"));
+ const to = parseIsoDate(req.nextUrl.searchParams.get("to"));
+ if (!from || !to) {
+ return NextResponse.json(
+ { error: "Paramètres from et to (YYYY-MM-DD) requis." },
+ { status: 400 },
+ );
+ }
+ const start = normalizeUtcDayStart(from);
+ const end = normalizeUtcDayStart(to);
+ if (end <= start) {
+ return NextResponse.json({ error: "to doit être > from." }, { status: 400 });
+ }
+ const calendar = await getItemAvailability(id, start, end);
+ return NextResponse.json({
+ itemId: id,
+ from: start.toISOString(),
+ to: end.toISOString(),
+ calendar,
+ });
+}
diff --git a/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx
new file mode 100644
index 0000000..4cdf5d9
--- /dev/null
+++ b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+type Day = {
+ date: string;
+ availableQty: number;
+ bookedQty: number;
+ totalQty: number;
+};
+
+export function AvailabilityPreview({ itemId }: { itemId: string }) {
+ const [calendar, setCalendar] = useState(null);
+
+ useEffect(() => {
+ const today = new Date();
+ today.setUTCHours(0, 0, 0, 0);
+ const to = new Date(today.getTime() + 30 * 86_400_000);
+ const fromStr = today.toISOString().slice(0, 10);
+ const toStr = to.toISOString().slice(0, 10);
+ fetch(`/api/rentals/items/${itemId}/availability?from=${fromStr}&to=${toStr}`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((j) => {
+ if (j?.calendar) setCalendar(j.calendar);
+ })
+ .catch(() => {});
+ }, [itemId]);
+
+ if (!calendar) {
+ return
;
+ }
+
+ return (
+
+
+ Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) :
+
+
+ {calendar.map((d) => {
+ const ratio = d.availableQty / Math.max(1, d.totalQty);
+ const tone =
+ d.availableQty === 0 ? "bg-zinc-300" :
+ ratio < 0.3 ? "bg-amber-300" :
+ "bg-emerald-400";
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx
new file mode 100644
index 0000000..b6f6aa1
--- /dev/null
+++ b/src/app/materiel/[itemId]/page.tsx
@@ -0,0 +1,159 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { getPublicRentalItem } from "@/lib/rentals-public";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+import { AvailabilityPreview } from "./_components/AvailabilityPreview";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ itemId: string }> };
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { itemId } = await params;
+ const item = await getPublicRentalItem(itemId);
+ if (!item) return { title: "Item introuvable", robots: { index: false } };
+ return {
+ title: `${item.name} — Location matériel`,
+ description: item.description ?? `Location de ${item.name} via ${item.provider.name}.`,
+ };
+}
+
+export default async function RentalItemDetailPage({ params }: PageProps) {
+ const { itemId } = await params;
+ const item = await getPublicRentalItem(itemId);
+ if (!item) notFound();
+
+ const categoryEmoji =
+ item.category === "SLEEP" ? "💤" :
+ item.category === "NAVIGATION" ? "🛶" :
+ item.category === "FISHING" ? "🎣" :
+ item.category === "COOKING" ? "🍳" : "🦺";
+
+ return (
+
+
+ ← Tout le matériel
+
+
+
+
+
+
+ {RENTAL_CATEGORY_LABEL[item.category]}
+
+ {item.name}
+
+ Loué par {item.provider.name}
+ {item.provider.isSystemD ? (
+
+ Fournisseur Karbé
+
+ ) : null}
+
+
+
+
+ {item.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {categoryEmoji}
+
+ )}
+
+
+ {item.description ? (
+
+ Description
+ {item.description}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {Number(item.pricePerDay).toFixed(0)} €
+
+ / jour
+
+
+ {item.pricePerWeek ? (
+
+ Forfait semaine : {Number(item.pricePerWeek).toFixed(0)} € (≥ 7 jours)
+
+ ) : null}
+
+
+
+ 🛒 La fonction « Ajouter au panier » arrive avec le Sprint D.
+ Pour réserver maintenant, contactez directement le prestataire.
+
+
+
+
{item.provider.name}
+ {item.provider.isSystemD ? (
+
Fournisseur officiel Karbé (0% commission).
+ ) : null}
+ {item.provider.description ? (
+
{item.provider.description}
+ ) : null}
+
+ {item.provider.contactEmail ? (
+
📧 {item.provider.contactEmail}
+ ) : null}
+ {item.provider.contactPhone ? (
+
📞 {item.provider.contactPhone}
+ ) : null}
+
+ Fleuves desservis : {item.provider.rivers.join(", ") || "—"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/materiel/_components/rental-filters.tsx b/src/app/materiel/_components/rental-filters.tsx
new file mode 100644
index 0000000..90dc76e
--- /dev/null
+++ b/src/app/materiel/_components/rental-filters.tsx
@@ -0,0 +1,100 @@
+import Link from "next/link";
+
+import { RentalCategory } from "@/generated/prisma/enums";
+import { RENTAL_CATEGORIES, RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+type Props = {
+ filters: {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ river?: string;
+ };
+ rivers: string[];
+ providers: { id: string; name: string; isSystemD: boolean }[];
+};
+
+export function RentalFilters({ filters, rivers, providers }: Props) {
+ return (
+
+
+
+ Recherche
+
+
+
+ Fleuve
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+
+ Prestataire
+
+ Tous prestataires
+ {providers.map((p) => (
+
+ {p.name}{p.isSystemD ? " (Karbé)" : ""}
+
+ ))}
+
+
+
+
+
+ Catégorie
+
+ {RENTAL_CATEGORIES.map((c) => {
+ const checked = filters.category === c;
+ return (
+
+
+ {RENTAL_CATEGORY_LABEL[c]}
+
+ );
+ })}
+
+
+
+
+
+ Filtrer
+
+
+ Réinit.
+
+
+
+ );
+}
diff --git a/src/app/materiel/_components/rental-item-card.tsx b/src/app/materiel/_components/rental-item-card.tsx
new file mode 100644
index 0000000..179750b
--- /dev/null
+++ b/src/app/materiel/_components/rental-item-card.tsx
@@ -0,0 +1,76 @@
+import Link from "next/link";
+
+import type { PublicRentalItem } from "@/lib/rentals-public";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+export function RentalItemCard({ item }: { item: PublicRentalItem }) {
+ return (
+
+
+ {item.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {item.category === "SLEEP" ? "💤" :
+ item.category === "NAVIGATION" ? "🛶" :
+ item.category === "FISHING" ? "🎣" :
+ item.category === "COOKING" ? "🍳" : "🦺"}
+
+ )}
+
+ {RENTAL_CATEGORY_LABEL[item.category]}
+
+ {item.provider.isSystemD ? (
+
+ Karbé
+
+ ) : null}
+
+
+
+ {item.name}
+
+
{item.provider.name}
+
{item.description ?? ""}
+
+ {item.withMotor ? (
+ ⚙️ moteur
+ ) : null}
+ {item.requiresLicense ? (
+ 🪪 permis
+ ) : null}
+ {item.fuelIncluded ? (
+ ⛽ essence
+ ) : null}
+ {Number(item.deposit) > 0 ? (
+
+ Caution {Number(item.deposit).toFixed(0)} €
+
+ ) : null}
+
+
+
+
+ {Number(item.pricePerDay).toFixed(0)} €
+
+ / jour
+
+ {item.pricePerWeek ? (
+
+ {Number(item.pricePerWeek).toFixed(0)} € / semaine
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/app/materiel/page.tsx b/src/app/materiel/page.tsx
new file mode 100644
index 0000000..f18f2ac
--- /dev/null
+++ b/src/app/materiel/page.tsx
@@ -0,0 +1,121 @@
+import type { Metadata } from "next";
+
+import { RentalCategory } from "@/generated/prisma/enums";
+import { isRentalCategory } from "@/lib/rental-category-labels";
+import {
+ listPublicProviders,
+ listPublicRentalItems,
+ listPublicRivers,
+} from "@/lib/rentals-public";
+
+import { RentalFilters } from "./_components/rental-filters";
+import { RentalItemCard } from "./_components/rental-item-card";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Louer du matériel",
+ description:
+ "Hamac, moustiquaire, pirogue, kayak, barque, gilet, réchaud… Toutes les locations de matériel pour réussir votre séjour en carbet guyanais, fournies par l'association System D et des prestataires locaux validés.",
+};
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ category?: string;
+ providerId?: string;
+ river?: string;
+ }>;
+};
+
+export default async function MaterialPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ category: sp.category && isRentalCategory(sp.category) ? (sp.category as RentalCategory) : undefined,
+ providerId: sp.providerId || undefined,
+ river: sp.river || undefined,
+ };
+ const [items, providers, rivers] = await Promise.all([
+ listPublicRentalItems(filters),
+ listPublicProviders(),
+ listPublicRivers(),
+ ]);
+
+ return (
+
+
+
+ Matériel à louer
+
+
+ Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage…
+ Tout le matériel pour réussir votre séjour, mis à disposition par
+ l'association System D ou par des prestataires
+ locaux validés.
+
+
+
+
+
+
+
+ {items.length} item{items.length > 1 ? "s" : ""} disponible
+ {items.length > 1 ? "s" : ""}
+
+ {items.length === 0 ? (
+
+ Aucun item ne correspond à votre recherche. Essayez d'élargir
+ les filtres.
+
+ ) : (
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+ )}
+
+
+ {providers.length > 0 ? (
+
+
+ Nos prestataires partenaires
+
+
+ {providers.length} prestataire{providers.length > 1 ? "s" : ""} valid
+ {providers.length > 1 ? "és" : "é"} sur Karbé.
+
+
+ {providers.map((p) => (
+
+
+
{p.name}
+ {p.isSystemD ? (
+
+ Karbé
+
+ ) : null}
+
+
+ Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item
+ {p.itemsCount > 1 ? "s" : ""}
+
+ {p.description ? (
+
+ {p.description}
+
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
index 08ffe77..564c466 100644
--- a/src/components/SiteHeader.tsx
+++ b/src/components/SiteHeader.tsx
@@ -33,8 +33,8 @@ export async function SiteHeader() {
Catalogue
-
- Comment ça marche
+
+ Matériel
diff --git a/src/lib/rentals-public.ts b/src/lib/rentals-public.ts
new file mode 100644
index 0000000..af08f4a
--- /dev/null
+++ b/src/lib/rentals-public.ts
@@ -0,0 +1,181 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalCategory } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type PublicRentalFilters = {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ river?: string;
+};
+
+export type PublicRentalItem = {
+ id: string;
+ name: string;
+ description: string | null;
+ category: RentalCategory;
+ imageUrl: string | null;
+ pricePerDay: string;
+ pricePerWeek: string | null;
+ deposit: string;
+ totalQty: number;
+ withMotor: boolean;
+ fuelIncluded: boolean;
+ requiresLicense: boolean;
+ provider: {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ rivers: string[];
+ };
+};
+
+export async function listPublicRentalItems(
+ filters: PublicRentalFilters = {},
+): Promise {
+ const where: Prisma.RentalItemWhereInput = {
+ active: true,
+ provider: { active: true, approved: true },
+ };
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.category) where.category = filters.category;
+ if (filters.providerId) where.providerId = filters.providerId;
+ if (filters.river) {
+ where.provider = { active: true, approved: true, rivers: { has: filters.river } };
+ }
+
+ const rows = await prisma.rentalItem.findMany({
+ where,
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ take: 200,
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true, rivers: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ description: r.description,
+ category: r.category,
+ imageUrl: r.imageUrl,
+ pricePerDay: r.pricePerDay.toString(),
+ pricePerWeek: r.pricePerWeek?.toString() ?? null,
+ deposit: r.deposit.toString(),
+ totalQty: r.totalQty,
+ withMotor: r.withMotor,
+ fuelIncluded: r.fuelIncluded,
+ requiresLicense: r.requiresLicense,
+ provider: r.provider,
+ }));
+}
+
+export async function getPublicRentalItem(id: string) {
+ return prisma.rentalItem.findFirst({
+ where: { id, active: true, provider: { active: true, approved: true } },
+ include: {
+ provider: {
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ rivers: true,
+ description: true,
+ contactEmail: true,
+ contactPhone: true,
+ },
+ },
+ },
+ });
+}
+
+export type PublicProvider = {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ rivers: string[];
+ itemsCount: number;
+ description: string | null;
+};
+
+export async function listPublicProviders(): Promise {
+ const rows = await prisma.rentalProvider.findMany({
+ where: { active: true, approved: true },
+ orderBy: [{ isSystemD: "desc" }, { name: "asc" }],
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ rivers: true,
+ description: true,
+ _count: { select: { items: { where: { active: true } } } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ isSystemD: r.isSystemD,
+ rivers: r.rivers,
+ description: r.description,
+ itemsCount: r._count.items,
+ }));
+}
+
+export async function listPublicRivers(): Promise {
+ const rows = await prisma.rentalProvider.findMany({
+ where: { active: true, approved: true },
+ select: { rivers: true },
+ });
+ const set = new Set();
+ for (const r of rows) for (const x of r.rivers) set.add(x);
+ return Array.from(set).sort();
+}
+
+/**
+ * Calcule la disponibilité d'un item sur une plage : pour chaque jour, qty
+ * réservée (somme des RentalItemAvailability qui couvrent ce jour) vs
+ * totalQty. Renvoie la qty disponible jour par jour.
+ */
+export async function getItemAvailability(
+ itemId: string,
+ from: Date,
+ to: Date,
+): Promise<{ date: string; availableQty: number; bookedQty: number; totalQty: number }[]> {
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: itemId },
+ select: { totalQty: true },
+ });
+ if (!item) return [];
+
+ const blocks = await prisma.rentalItemAvailability.findMany({
+ where: {
+ itemId,
+ startDate: { lt: to },
+ endDate: { gt: from },
+ },
+ select: { startDate: true, endDate: true, qty: true },
+ });
+
+ const days: { date: string; availableQty: number; bookedQty: number; totalQty: number }[] = [];
+ const DAY_MS = 86_400_000;
+ for (let t = from.getTime(); t < to.getTime(); t += DAY_MS) {
+ const dayStart = new Date(t);
+ const dayEnd = new Date(t + DAY_MS);
+ const booked = blocks
+ .filter((b) => b.startDate < dayEnd && b.endDate > dayStart)
+ .reduce((acc, b) => acc + b.qty, 0);
+ days.push({
+ date: dayStart.toISOString().slice(0, 10),
+ bookedQty: booked,
+ availableQty: Math.max(0, item.totalQty - booked),
+ totalQty: item.totalQty,
+ });
+ }
+ return days;
+}
From 59786e536565009a4bc412ddb8faa26770ca90e7 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 08:01:42 +0000
Subject: [PATCH 17/34] =?UTF-8?q?feat(rental):=20Sprint=20C=20=E2=80=94=20?=
=?UTF-8?q?espace=20prestataire=20(signup+dashboard+items+calendrier+r?=
=?UTF-8?q?=C3=A9sa)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/api/signup/route.ts | 39 ++-
src/app/espace-prestataire/actions.ts | 237 ++++++++++++++++++
.../_components/ItemBlocksManager.tsx | 151 +++++++++++
.../[itemId]/_components/ItemInlineDelete.tsx | 71 ++++++
.../items/[itemId]/page.tsx | 107 ++++++++
.../items/_components/ItemForm.tsx | 133 ++++++++++
src/app/espace-prestataire/items/new/page.tsx | 23 ++
src/app/espace-prestataire/items/page.tsx | 93 +++++++
src/app/espace-prestataire/page.tsx | 153 +++++++++++
.../_components/BookingDecision.tsx | 96 +++++++
.../espace-prestataire/reservations/page.tsx | 137 ++++++++++
.../inscription/_components/SignupForm.tsx | 77 +++++-
src/components/SiteHeader.tsx | 6 +
src/lib/email.ts | 21 ++
src/lib/rental-access.ts | 54 ++++
src/lib/rental-host.ts | 120 +++++++++
16 files changed, 1509 insertions(+), 9 deletions(-)
create mode 100644 src/app/espace-prestataire/actions.ts
create mode 100644 src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx
create mode 100644 src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx
create mode 100644 src/app/espace-prestataire/items/[itemId]/page.tsx
create mode 100644 src/app/espace-prestataire/items/_components/ItemForm.tsx
create mode 100644 src/app/espace-prestataire/items/new/page.tsx
create mode 100644 src/app/espace-prestataire/items/page.tsx
create mode 100644 src/app/espace-prestataire/page.tsx
create mode 100644 src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
create mode 100644 src/app/espace-prestataire/reservations/page.tsx
create mode 100644 src/lib/rental-access.ts
create mode 100644 src/lib/rental-host.ts
diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts
index 1ded993..8953cf7 100644
--- a/src/app/api/signup/route.ts
+++ b/src/app/api/signup/route.ts
@@ -5,7 +5,7 @@ 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 { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
@@ -16,11 +16,14 @@ const schema = z.object({
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),
+ role: z
+ .enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER])
+ .default(UserRole.TOURIST),
+ providerName: z.string().trim().min(2).max(200).optional(),
+ providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
});
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(
@@ -43,6 +46,10 @@ export async function POST(req: Request) {
}
const data = parsed.data;
+ if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
+ return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
+ }
+
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 });
@@ -62,16 +69,36 @@ export async function POST(req: Request) {
select: { id: true, email: true, role: true },
});
+ // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
+ let createdProviderId: string | null = null;
+ if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
+ const provider = await prisma.rentalProvider.create({
+ data: {
+ name: data.providerName,
+ isSystemD: false,
+ managedByUserId: user.id,
+ contactEmail: user.email,
+ contactPhone: data.phone?.trim() || null,
+ rivers: data.providerRivers ?? [],
+ commissionPct: 10, // valeur par défaut, ajustable par admin
+ active: true,
+ approved: false,
+ },
+ select: { id: true, name: true },
+ });
+ createdProviderId = provider.id;
+ sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
+ }
+
await recordAudit({
scope: "public.signup",
event: "user.create",
target: user.id,
actorEmail: user.email,
- details: { role: user.role },
+ details: { role: user.role, rentalProviderId: createdProviderId },
});
- // Best-effort welcome email.
sendSignupWelcome(user.email, data.firstName).catch(() => {});
- return NextResponse.json({ ok: true, userId: user.id });
+ return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId });
}
diff --git a/src/app/espace-prestataire/actions.ts b/src/app/espace-prestataire/actions.ts
new file mode 100644
index 0000000..2474e91
--- /dev/null
+++ b/src/app/espace-prestataire/actions.ts
@@ -0,0 +1,237 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums";
+import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const itemSchema = z.object({
+ 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(),
+});
+
+async function requireOwnedProvider(): Promise<{ providerId: string; actorEmail: string | null }> {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Non authentifié");
+ const provider = await getCurrentRentalProvider();
+ if (!provider) throw new Error("Aucun provider associé");
+ return { providerId: provider.id, actorEmail: session.user.email ?? null };
+}
+
+function parseItemFD(fd: FormData) {
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ 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 createHostItemAction(fd: FormData) {
+ const { providerId, actorEmail } = await requireOwnedProvider();
+ const parsed = itemSchema.safeParse(parseItemFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "create",
+ target: created.id,
+ actorEmail,
+ details: { name: created.name, providerId },
+ });
+ revalidatePath("/espace-prestataire/items");
+ redirect(`/espace-prestataire/items/${created.id}`);
+}
+
+export async function updateHostItemAction(itemId: string, fd: FormData) {
+ const { providerId, actorEmail } = await requireOwnedProvider();
+ const session = await auth();
+ if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId))) {
+ return { ok: false as const, error: "Accès refusé" };
+ }
+ const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
+ if (!existing || existing.providerId !== providerId) {
+ return { ok: false as const, error: "Item introuvable." };
+ }
+ const parsed = itemSchema.safeParse(parseItemFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "update",
+ target: itemId,
+ actorEmail,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/espace-prestataire/items");
+ revalidatePath(`/espace-prestataire/items/${itemId}`);
+ return { ok: true as const };
+}
+
+export async function deleteHostItemAction(itemId: string) {
+ const { providerId, actorEmail } = await requireOwnedProvider();
+ const existing = await prisma.rentalItem.findUnique({
+ where: { id: itemId },
+ select: { providerId: true, _count: { select: { lines: true } } },
+ });
+ if (!existing || existing.providerId !== providerId) {
+ return { ok: false as const, error: "Item introuvable." };
+ }
+ if (existing._count.lines > 0) {
+ return { ok: false as const, error: "Impossible : item référencé par des locations." };
+ }
+ await prisma.rentalItem.delete({ where: { id: itemId } });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "delete",
+ target: itemId,
+ actorEmail,
+ details: {},
+ });
+ revalidatePath("/espace-prestataire/items");
+ redirect("/espace-prestataire/items");
+}
+
+const blockSchema = z.object({
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ qty: z.coerce.number().int().min(1).max(1000),
+ reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]),
+});
+
+export async function addItemBlockAction(itemId: string, fd: FormData) {
+ const { providerId, actorEmail } = await requireOwnedProvider();
+ const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
+ if (!existing || existing.providerId !== providerId) {
+ return { ok: false as const, error: "Item introuvable." };
+ }
+ const parsed = blockSchema.safeParse({
+ startDate: fd.get("startDate"),
+ endDate: fd.get("endDate"),
+ qty: fd.get("qty"),
+ reason: fd.get("reason"),
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
+ const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
+ if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." };
+
+ await prisma.rentalItemAvailability.create({
+ data: {
+ itemId,
+ startDate: start,
+ endDate: end,
+ qty: parsed.data.qty,
+ reason: parsed.data.reason,
+ },
+ });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "block.add",
+ target: itemId,
+ actorEmail,
+ details: { ...parsed.data },
+ });
+ revalidatePath(`/espace-prestataire/items/${itemId}`);
+ return { ok: true as const };
+}
+
+export async function removeItemBlockAction(blockId: string) {
+ const { providerId, actorEmail } = await requireOwnedProvider();
+ const block = await prisma.rentalItemAvailability.findUnique({
+ where: { id: blockId },
+ select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
+ });
+ if (!block || block.item.providerId !== providerId) {
+ return { ok: false as const, error: "Blocage introuvable." };
+ }
+ if (block.rentalBookingId) {
+ return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." };
+ }
+ await prisma.rentalItemAvailability.delete({ where: { id: blockId } });
+ await recordAudit({
+ scope: "host.rental-items",
+ event: "block.remove",
+ target: blockId,
+ actorEmail,
+ details: { itemId: block.itemId },
+ });
+ revalidatePath(`/espace-prestataire/items/${block.itemId}`);
+ return { ok: true as const };
+}
+
+const statusSchema = z.enum([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+export async function updateBookingStatusAction(bookingId: string, status: string) {
+ const { providerId, actorEmail } = await requireOwnedProvider();
+ const session = await auth();
+ const role = session?.user?.role;
+ const parsed = statusSchema.safeParse(status);
+ if (!parsed.success) return { ok: false as const, error: "Statut invalide." };
+
+ const existing = await prisma.rentalBooking.findUnique({
+ where: { id: bookingId },
+ select: { providerId: true },
+ });
+ if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) {
+ return { ok: false as const, error: "Réservation introuvable." };
+ }
+ await prisma.rentalBooking.update({
+ where: { id: bookingId },
+ data: { status: parsed.data },
+ });
+ await recordAudit({
+ scope: "host.rental-bookings",
+ event: "status.update",
+ target: bookingId,
+ actorEmail,
+ details: { status: parsed.data },
+ });
+ revalidatePath("/espace-prestataire/reservations");
+ return { ok: true as const };
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx
new file mode 100644
index 0000000..e83e53b
--- /dev/null
+++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Block = {
+ id: string;
+ startDate: string;
+ endDate: string;
+ qty: number;
+ reason: string;
+ isBooking: boolean;
+};
+
+type Props = {
+ blocks: Block[];
+ totalQty: number;
+ addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+};
+
+const REASON_LABEL: Record = {
+ MAINTENANCE: "🔧 Maintenance",
+ MANUAL_BLOCK: "⛔ Blocage personnel",
+ RENTAL_BOOKING: "🛒 Réservation",
+};
+
+export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ function onAdd(fd: FormData) {
+ setError(null);
+ startTransition(async () => {
+ const res = await addAction(fd);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ function onRemove(blockId: string) {
+ setError(null);
+ startTransition(async () => {
+ const res = await removeAction(blockId);
+ if (res && res.ok === false) setError(res.error);
+ router.refresh();
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx
new file mode 100644
index 0000000..bc81c8b
--- /dev/null
+++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+type Props = {
+ canDelete: boolean;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ItemInlineDelete({ canDelete, deleteAction }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [confirm, setConfirm] = useState(false);
+ const [error, setError] = useState(null);
+
+ function run() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirm(false);
+ }
+ });
+ }
+
+ if (!canDelete) {
+ return (
+
+ Suppression impossible — item référencé par des locations
+
+ );
+ }
+
+ return (
+
+ {confirm ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirm(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
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'item
+
+ )}
+ {error ? (
+
{error}
+ ) : null}
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/page.tsx b/src/app/espace-prestataire/items/[itemId]/page.tsx
new file mode 100644
index 0000000..699a8b0
--- /dev/null
+++ b/src/app/espace-prestataire/items/[itemId]/page.tsx
@@ -0,0 +1,107 @@
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { getHostItem } from "@/lib/rental-host";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+import { HostItemForm } from "../_components/ItemForm";
+import { ItemBlocksManager } from "./_components/ItemBlocksManager";
+import { ItemInlineDelete } from "./_components/ItemInlineDelete";
+import {
+ addItemBlockAction,
+ deleteHostItemAction,
+ removeItemBlockAction,
+ updateHostItemAction,
+} from "../../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ itemId: string }> };
+
+export default async function EditHostItemPage({ params }: PageProps) {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) redirect("/admin/rental-providers");
+ const { itemId } = await params;
+ const item = await getHostItem(provider.id, itemId);
+ if (!item) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateHostItemAction(itemId, fd);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteHostItemAction(itemId);
+ };
+ const addBlockThis = async (fd: FormData) => {
+ "use server";
+ return await addItemBlockAction(itemId, fd);
+ };
+ const removeBlockThis = async (blockId: string) => {
+ "use server";
+ return await removeItemBlockAction(blockId);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Calendrier de disponibilité
+
+
+ Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
+ confirmées sont gérées automatiquement.
+
+ ({
+ id: a.id,
+ startDate: a.startDate.toISOString().slice(0, 10),
+ endDate: a.endDate.toISOString().slice(0, 10),
+ qty: a.qty,
+ reason: a.reason,
+ isBooking: Boolean(a.rentalBookingId),
+ }))}
+ addAction={addBlockThis}
+ removeAction={removeBlockThis}
+ totalQty={item.totalQty}
+ />
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/_components/ItemForm.tsx b/src/app/espace-prestataire/items/_components/ItemForm.tsx
new file mode 100644
index 0000000..c0033ad
--- /dev/null
+++ b/src/app/espace-prestataire/items/_components/ItemForm.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
+
+const inputCls =
+ "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
+const labelCls = "block text-sm font-medium text-zinc-800";
+
+type Props = {
+ initial?: {
+ 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 HostItemForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(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 (
+
+
+
+
+ Catégorie
+
+ — sélectionner —
+ {RENTAL_CATEGORIES.map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+
+ Statut
+
+
+ Actif (visible au catalogue)
+
+
+
+ Nom de l'item
+
+
+
+ Description
+
+
+
+ URL image
+
+
+
+ Stock total (qté)
+
+
+
+ Prix / jour (€)
+
+
+
+ Prix / semaine (€)
+
+
+
+ Caution (€)
+
+
+
+
+
+
+ Spécifications
+
+
+
+
+ Avec moteur
+
+
+
+ Essence incluse
+
+
+
+ Permis bateau requis
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/new/page.tsx b/src/app/espace-prestataire/items/new/page.tsx
new file mode 100644
index 0000000..5431bdf
--- /dev/null
+++ b/src/app/espace-prestataire/items/new/page.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+
+import { requireRentalProviderSession } from "@/lib/rental-access";
+
+import { HostItemForm } from "../_components/ItemForm";
+import { createHostItemAction } from "../../actions";
+
+export const dynamic = "force-dynamic";
+
+export default async function NewHostItemPage() {
+ await requireRentalProviderSession();
+ return (
+
+
+ ← Mes items
+
+ Nouvel item
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/page.tsx b/src/app/espace-prestataire/items/page.tsx
new file mode 100644
index 0000000..355a5ae
--- /dev/null
+++ b/src/app/espace-prestataire/items/page.tsx
@@ -0,0 +1,93 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { listHostItems } from "@/lib/rental-host";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
+
+export const dynamic = "force-dynamic";
+
+export default async function HostItemsPage() {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) redirect("/admin/rental-providers");
+
+ const items = await listHostItems(provider.id);
+
+ return (
+
+
+
+ {items.length === 0 ? (
+
+ Pas encore d'item.{" "}
+
+ Créer mon premier item
+
+
+ ) : (
+
+
+
+
+ Nom
+ Catégorie
+ €/j
+ Stock
+ Caution
+ Résa
+ État
+
+
+
+ {items.map((i) => (
+
+
+
+ {i.name}
+
+
+ {i.withMotor ? "⚙️ moteur · " : ""}
+ {i.requiresLicense ? "🪪 permis · " : ""}
+ {i.fuelIncluded ? "⛽ essence " : ""}
+
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+ {Number(i.pricePerDay).toFixed(0)}
+ {i.totalQty}
+ {Number(i.deposit).toFixed(0)}
+ {i._count.lines}
+
+ {i.active ? (
+
+ Actif
+
+ ) : (
+
+ Inactif
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/espace-prestataire/page.tsx b/src/app/espace-prestataire/page.tsx
new file mode 100644
index 0000000..620aa63
--- /dev/null
+++ b/src/app/espace-prestataire/page.tsx
@@ -0,0 +1,153 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { getHostRentalKpis } from "@/lib/rental-host";
+
+export const dynamic = "force-dynamic";
+
+function fmtEur(amount: string | number): string {
+ const n = Number(amount);
+ return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
+}
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+});
+
+export default async function ProviderDashboardPage() {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) {
+ // Admin sans providerId ciblé : redirect vers liste admin
+ redirect("/admin/rental-providers");
+ }
+
+ const kpis = await getHostRentalKpis(provider.id);
+
+ return (
+
+
+
+ {!provider.approved ? (
+
+
Compte en attente de validation
+
+ Vos items ne sont pas encore visibles sur le catalogue public.
+ L'équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
+ votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
+
+
+ ) : null}
+
+
+
+
+ 0 ? "warn" : "neutral"}
+ />
+
+
+
+
+
+ {kpis.nextHandover ? (
+
+ Prochaine remise
+
+ {kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
+
+
+ {dateFmt.format(kpis.nextHandover.startDate)}
+
+
+ Voir le détail →
+
+
+ ) : null}
+
+
+ Mon activité
+
+
+ Fleuves desservis : {" "}
+ {provider.rivers.join(", ") || "—"}
+
+
+ Commission Karbé : {" "}
+ {Number(provider.commissionPct).toFixed(1)}%
+
+
+ Statut : {" "}
+ {provider.active ? "Actif" : "Inactif"}
+ {" · "}
+ {provider.approved ? "Approuvé" : "En attente"}
+
+
+
+
+ );
+}
+
+function Kpi({
+ label,
+ value,
+ tone = "neutral",
+}: {
+ label: string;
+ value: string;
+ tone?: "neutral" | "warn";
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
new file mode 100644
index 0000000..2d6fa77
--- /dev/null
+++ b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import { RentalBookingStatus } from "@/generated/prisma/enums";
+
+import { updateBookingStatusAction } from "../../actions";
+
+const btnBase =
+ "rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
+
+export function BookingDecision({ bookingId, status }: { bookingId: string; status: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [confirmCancel, setConfirmCancel] = useState(false);
+
+ function set(next: string) {
+ setError(null);
+ startTransition(async () => {
+ const res = await updateBookingStatusAction(bookingId, next);
+ if (res && res.ok === false) setError(res.error);
+ setConfirmCancel(false);
+ router.refresh();
+ });
+ }
+
+ return (
+
+ {status === RentalBookingStatus.PENDING ? (
+
set(RentalBookingStatus.CONFIRMED)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Confirmer
+
+ ) : null}
+ {status === RentalBookingStatus.CONFIRMED ? (
+
set(RentalBookingStatus.HANDED_OVER)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer remis client
+
+ ) : null}
+ {status === RentalBookingStatus.HANDED_OVER ? (
+
set(RentalBookingStatus.RETURNED)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer retourné
+
+ ) : null}
+ {status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? (
+ confirmCancel ? (
+
+ Annuler ?
+ set(RentalBookingStatus.CANCELLED)}
+ 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
+
+ setConfirmCancel(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Non
+
+
+ ) : (
+
setConfirmCancel(true)}
+ disabled={pending}
+ className={`${btnBase} border border-rose-300 bg-white text-rose-700 hover:bg-rose-50`}
+ >
+ Annuler
+
+ )
+ ) : null}
+ {error ?
{error} : null}
+
+ );
+}
diff --git a/src/app/espace-prestataire/reservations/page.tsx b/src/app/espace-prestataire/reservations/page.tsx
new file mode 100644
index 0000000..b66f063
--- /dev/null
+++ b/src/app/espace-prestataire/reservations/page.tsx
@@ -0,0 +1,137 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { RentalBookingStatus } from "@/generated/prisma/enums";
+import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
+import { listHostBookings } from "@/lib/rental-host";
+import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
+
+import { BookingDecision } from "./_components/BookingDecision";
+
+export const dynamic = "force-dynamic";
+
+const STATUS_VALUES = new Set([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+type PageProps = {
+ searchParams: Promise<{ status?: string }>;
+};
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "2-digit",
+});
+
+export default async function HostReservationsPage({ searchParams }: PageProps) {
+ await requireRentalProviderSession();
+ const provider = await getCurrentRentalProvider();
+ if (!provider) redirect("/admin/rental-providers");
+ const sp = await searchParams;
+ const status = STATUS_VALUES.has(sp.status ?? "")
+ ? (sp.status as RentalBookingStatus)
+ : undefined;
+
+ const bookings = await listHostBookings(provider.id, { status });
+
+ return (
+
+
+
+ {bookings.length === 0 ? (
+
+ Aucune réservation matériel.
+
+ ) : (
+
+ {bookings.map((b) => (
+
+
+
+
+ {b.tenant.firstName} {b.tenant.lastName}
+
+
+ {b.tenant.email}
+ {b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
+
+ {b.booking ? (
+
+ 🏠 Lié à la résa carbet :{" "}
+
+ {b.booking.carbet.title}
+
+
+ ) : (
+
Location standalone (sans carbet)
+ )}
+
+
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
+
+
+ {Number(b.amount).toFixed(2)} {b.currency}
+
+
+
+
+
+ {b.lines.map((l) => (
+
+
+ {l.qty}× {l.item.name}
+
+
+ {Number(l.lineTotal).toFixed(2)} €
+
+
+ ))}
+
+
+
+
+
+ {RENTAL_STATUS_LABEL[b.status]}
+
+
+ {b.paymentStatus}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx
index 2ffd914..6f8f7bd 100644
--- a/src/app/inscription/_components/SignupForm.tsx
+++ b/src/app/inscription/_components/SignupForm.tsx
@@ -10,7 +10,9 @@ export function SignupForm({ next }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState(null);
- const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST");
+ const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER">("TOURIST");
+ const [providerName, setProviderName] = useState("");
+ const [providerRivers, setProviderRivers] = useState("");
function onSubmit(formData: FormData) {
setError(null);
@@ -24,12 +26,31 @@ export function SignupForm({ next }: Props) {
setError("Le mot de passe doit faire au moins 8 caractères.");
return;
}
+ if (role === "RENTAL_PROVIDER" && providerName.trim().length < 2) {
+ setError("Le nom de votre activité de loueur est requis.");
+ return;
+ }
startTransition(async () => {
+ const body: Record = {
+ email,
+ password,
+ firstName,
+ lastName,
+ phone: phone || null,
+ role,
+ };
+ if (role === "RENTAL_PROVIDER") {
+ body.providerName = providerName.trim();
+ body.providerRivers = providerRivers
+ .split(/[,;\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ }
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }),
+ body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -91,7 +112,7 @@ export function SignupForm({ next }: Props) {
Type de compte
-
+
Hôte
Publier un carbet.
+
+ setRole("RENTAL_PROVIDER")}
+ className="sr-only"
+ />
+ Loueur matériel
+ Hamac, pirogue, kayak…
+
+ {role === "RENTAL_PROVIDER" ? (
+
+ ) : null}
+
{error ? (
{error}
) : null}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
index 564c466..f823a9e 100644
--- a/src/components/SiteHeader.tsx
+++ b/src/components/SiteHeader.tsx
@@ -15,6 +15,7 @@ export async function SiteHeader() {
const u = session?.user;
const isAdmin = u?.role === UserRole.ADMIN;
const isOwner = u?.role === UserRole.OWNER || isAdmin;
+ const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
return (
+
+
-
-
- {children}
+
+
+
+ {children}
+