From 1e6acf29b9cd906917ca72c1aab1e1ae825b491a Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 16:16:25 +0000 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20dashboard=20espace=20h=C3=B4te=20?= =?UTF-8?q?(KPIs=20+=20r=C3=A9sa=20pending=20+=20carbets=20+=20activit?= =?UTF-8?q?=C3=A9)=20+=20lightbox=20galerie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carbets/_components/carbet-gallery.tsx | 222 ++++++++++++++---- .../_components/BookingDecision.tsx | 77 ++++++ src/app/espace-hote/actions.ts | 75 ++++++ src/lib/host-dashboard.ts | 203 ++++++++++++++++ 4 files changed, 527 insertions(+), 50 deletions(-) create mode 100644 src/app/espace-hote/_components/BookingDecision.tsx create mode 100644 src/app/espace-hote/actions.ts create mode 100644 src/lib/host-dashboard.ts diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx index 807adda..a5c7ca1 100644 --- a/src/app/carbets/_components/carbet-gallery.tsx +++ b/src/app/carbets/_components/carbet-gallery.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + import type { PublicCarbetMedia } from "@/lib/carbet-public"; import { MediaType } from "@/generated/prisma/enums"; @@ -6,9 +10,36 @@ type Props = { media: PublicCarbetMedia[]; }; -// SSR-friendly gallery: shows a cover (photo or video) plus a strip of -// secondary media. No client component — all native HTML controls. +/** + * Galerie publique : grille de vignettes ; clic = lightbox plein écran avec + * navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe. + */ export function CarbetGallery({ title, media }: Props) { + const [active, setActive] = useState(null); + + const close = useCallback(() => setActive(null), []); + const next = useCallback(() => { + setActive((i) => (i === null ? null : (i + 1) % media.length)); + }, [media.length]); + const prev = useCallback(() => { + setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length)); + }, [media.length]); + + useEffect(() => { + if (active === null) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") close(); + else if (e.key === "ArrowRight") next(); + else if (e.key === "ArrowLeft") prev(); + } + window.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [active, close, next, prev]); + if (media.length === 0) { return (
@@ -17,57 +48,148 @@ export function CarbetGallery({ title, media }: Props) { ); } - const [cover, ...rest] = media; + const cover = media[0]; + const rest = media.slice(1); + const current = active === null ? null : media[active]; return ( -
-
- {cover.type === MediaType.VIDEO ? ( -
+ <> +
+ - {rest.length > 0 ? ( -
    - {rest.map((item) => ( -
  • - {item.type === MediaType.VIDEO ? ( -
  • - ))} -
+ {rest.length > 0 ? ( +
    + {rest.map((item, idx) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ + {current ? ( +
+ + + {media.length > 1 ? ( + <> + + + + ) : null} + +
e.stopPropagation()} + > + {current.type === MediaType.VIDEO ? ( +
+ +
+ {active! + 1} / {media.length} +
+
) : null} -
+ ); } diff --git a/src/app/espace-hote/_components/BookingDecision.tsx b/src/app/espace-hote/_components/BookingDecision.tsx new file mode 100644 index 0000000..9380ea2 --- /dev/null +++ b/src/app/espace-hote/_components/BookingDecision.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { confirmBookingAsHost, rejectBookingAsHost } from "../actions"; + +export function BookingDecision({ bookingId }: { bookingId: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirmReject, setConfirmReject] = useState(false); + const [error, setError] = useState(null); + + function accept() { + setError(null); + startTransition(async () => { + const res = await confirmBookingAsHost(bookingId); + if (res && res.ok === false) setError(res.error); + router.refresh(); + }); + } + function reject() { + setError(null); + startTransition(async () => { + const res = await rejectBookingAsHost(bookingId); + if (res && res.ok === false) setError(res.error); + setConfirmReject(false); + router.refresh(); + }); + } + + return ( +
+ {confirmReject ? ( +
+ Refuser ? + + +
+ ) : ( + <> + + + + )} + {error ? {error} : null} +
+ ); +} diff --git a/src/app/espace-hote/actions.ts b/src/app/espace-hote/actions.ts new file mode 100644 index 0000000..fa9208c --- /dev/null +++ b/src/app/espace-hote/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { BookingStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; +import { sendBookingConfirmed } from "@/lib/email"; + +async function requireBookingOwnership(bookingId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error("Non authentifié"); + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + carbet: { select: { ownerId: true, title: true } }, + tenant: { select: { email: true, firstName: true } }, + }, + }); + if (!booking) throw new Error("Réservation introuvable"); + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isAdmin && booking.carbet.ownerId !== session.user.id) { + throw new Error("Accès refusé"); + } + return { session, booking }; +} + +export async function confirmBookingAsHost(bookingId: string) { + const { session, booking } = await requireBookingOwnership(bookingId); + if (booking.status !== BookingStatus.PENDING) { + return { ok: false as const, error: "Cette réservation ne peut plus être confirmée." }; + } + const updated = await prisma.booking.update({ + where: { id: bookingId }, + data: { status: BookingStatus.CONFIRMED }, + }); + await recordAudit({ + scope: "host.bookings", + event: "confirm", + target: bookingId, + actorEmail: session.user.email ?? null, + details: {}, + }); + sendBookingConfirmed( + booking.tenant.email, + booking.tenant.firstName, + bookingId, + booking.carbet.title, + updated.startDate, + updated.endDate, + ).catch(() => {}); + revalidatePath("/espace-hote"); + return { ok: true as const }; +} + +export async function rejectBookingAsHost(bookingId: string) { + const { session, booking } = await requireBookingOwnership(bookingId); + if (booking.status !== BookingStatus.PENDING) { + return { ok: false as const, error: "Cette réservation ne peut plus être refusée." }; + } + await prisma.booking.update({ + where: { id: bookingId }, + data: { status: BookingStatus.CANCELLED }, + }); + await recordAudit({ + scope: "host.bookings", + event: "reject", + target: bookingId, + actorEmail: session.user.email ?? null, + details: {}, + }); + revalidatePath("/espace-hote"); + return { ok: true as const }; +} diff --git a/src/lib/host-dashboard.ts b/src/lib/host-dashboard.ts new file mode 100644 index 0000000..586ff65 --- /dev/null +++ b/src/lib/host-dashboard.ts @@ -0,0 +1,203 @@ +import "server-only"; + +import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type HostKpis = { + revenueTotal: string; + revenue30d: string; + revenue365d: string; + bookingsPending: number; + bookingsConfirmedUpcoming: number; + bookingsTotal: number; + carbetsCount: number; + carbetsPublished: number; + occupancyRate30d: number; // 0..1 + nextArrival: { id: string; carbetTitle: string; startDate: Date; tenantName: string } | null; +}; + +type Scope = { ownerId: string; isAdmin: boolean }; + +function scopeWhere(scope: Scope) { + return scope.isAdmin ? {} : { carbet: { ownerId: scope.ownerId } }; +} + +function carbetWhere(scope: Scope) { + return scope.isAdmin ? {} : { ownerId: scope.ownerId }; +} + +export async function getHostKpis(scope: Scope): Promise { + const now = new Date(); + const last30 = new Date(now.getTime() - 30 * 86_400_000); + const last365 = new Date(now.getTime() - 365 * 86_400_000); + + const [revAll, rev30, rev365, pending, upcomingConfirmed, total, carbetsTotal, carbetsPub, nextArrival] = + await Promise.all([ + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + }, + _sum: { amount: true }, + }), + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + createdAt: { gte: last30 }, + }, + _sum: { amount: true }, + }), + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + createdAt: { gte: last365 }, + }, + _sum: { amount: true }, + }), + prisma.booking.count({ + where: { ...scopeWhere(scope), status: BookingStatus.PENDING }, + }), + prisma.booking.count({ + where: { + ...scopeWhere(scope), + status: BookingStatus.CONFIRMED, + startDate: { gte: now }, + }, + }), + prisma.booking.count({ where: scopeWhere(scope) }), + prisma.carbet.count({ where: carbetWhere(scope) }), + prisma.carbet.count({ where: { ...carbetWhere(scope), status: "PUBLISHED" } }), + prisma.booking.findFirst({ + where: { + ...scopeWhere(scope), + status: BookingStatus.CONFIRMED, + startDate: { gte: now }, + }, + orderBy: { startDate: "asc" }, + select: { + id: true, + startDate: true, + carbet: { select: { title: true } }, + tenant: { select: { firstName: true, lastName: true } }, + }, + }), + ]); + + // Taux d'occupation 30j : nuits réservées / (carbets publiés × 30) + const occupiedNights = await prisma.booking.findMany({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + startDate: { lt: now }, + endDate: { gte: last30 }, + }, + select: { startDate: true, endDate: true }, + }); + let totalNightsOccupied = 0; + for (const b of occupiedNights) { + const s = Math.max(b.startDate.getTime(), last30.getTime()); + const e = Math.min(b.endDate.getTime(), now.getTime()); + if (e > s) totalNightsOccupied += Math.floor((e - s) / 86_400_000); + } + const denom = Math.max(1, carbetsPub * 30); + const occupancyRate30d = Math.min(1, totalNightsOccupied / denom); + + return { + revenueTotal: (revAll._sum.amount ?? 0).toString(), + revenue30d: (rev30._sum.amount ?? 0).toString(), + revenue365d: (rev365._sum.amount ?? 0).toString(), + bookingsPending: pending, + bookingsConfirmedUpcoming: upcomingConfirmed, + bookingsTotal: total, + carbetsCount: carbetsTotal, + carbetsPublished: carbetsPub, + occupancyRate30d, + nextArrival: nextArrival + ? { + id: nextArrival.id, + carbetTitle: nextArrival.carbet.title, + startDate: nextArrival.startDate, + tenantName: `${nextArrival.tenant.firstName} ${nextArrival.tenant.lastName}`.trim(), + } + : null, + }; +} + +export type HostRecentBooking = { + id: string; + carbetId: string; + carbetTitle: string; + carbetSlug: string; + tenantName: string; + startDate: Date; + endDate: Date; + guestCount: number; + status: BookingStatus; + paymentStatus: PaymentStatus; + amount: string; + currency: string; +}; + +export async function listHostRecentBookings( + scope: Scope, + limit = 10, +): Promise { + const rows = await prisma.booking.findMany({ + where: scopeWhere(scope), + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + take: limit, + select: { + id: true, + startDate: true, + endDate: true, + guestCount: true, + status: true, + paymentStatus: true, + amount: true, + currency: true, + carbet: { select: { id: true, title: true, slug: true } }, + tenant: { select: { firstName: true, lastName: true } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + carbetId: r.carbet.id, + carbetTitle: r.carbet.title, + carbetSlug: r.carbet.slug, + tenantName: `${r.tenant.firstName} ${r.tenant.lastName}`.trim(), + startDate: r.startDate, + endDate: r.endDate, + guestCount: r.guestCount, + status: r.status, + paymentStatus: r.paymentStatus, + amount: r.amount.toString(), + currency: r.currency, + })); +} + +export async function listHostCarbets(scope: Scope) { + const rows = await prisma.carbet.findMany({ + where: carbetWhere(scope), + orderBy: [{ updatedAt: "desc" }], + select: { + id: true, + slug: true, + title: true, + status: true, + nightlyPrice: true, + capacity: true, + river: true, + _count: { select: { bookings: true, reviews: true, media: true } }, + }, + }); + return rows.map((r) => ({ ...r, nightlyPrice: r.nightlyPrice.toString() })); +} + +export function isScopeAdmin(role: UserRole | string | undefined): boolean { + return role === UserRole.ADMIN; +} From 55c024433643a59bcd6320bf232365c96a88a923 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 16:20:06 +0000 Subject: [PATCH 02/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}). -

+
+
+
+

Espace hôte

+

+ Bienvenue {session?.user?.name || session?.user?.email}.{" "} + {isAdmin ? "Vue globale (admin)." : "Vue limitée à vos carbets."} +

+
+
+ + + Nouveau carbet + + + Tous mes carbets + +
+
-
- - 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 + +
+ ) : ( +
+ + + + + + + + + + + + + + + {carbets.map((c) => ( + + + + + + + + + + + ))} + +
TitreFleuve€/nuitCap.MédiasRésasAvisStatut
+ + {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 +

+
+ + + + + + + + + + + + + {recent.map((b) => ( + + + + + + + + + ))} + +
CarbetLocataireSéjourMontantRésaPaiement
{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 03/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 04/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 05/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 + {p.title} + ) : 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 06/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 07/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" ? ( +
+ + {/* 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 */} + + + + + {current.type === "VIDEO" ? ( + + ) : 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.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") + } + > + + +
+ + {uploads.length > 0 ? ( +
    + {uploads.map((u) => ( +
  • +
    + {u.name} + + {u.error + ? "❌" + : u.done + ? "✓" + : `${Math.round(u.sizeBytes / 1000)} ko · ${u.progress}%`} + +
    +
    +
    +
    + {u.error ?
    {u.error}
    : null} +
  • + ))} +
+ ) : 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" ? ( +
+ {isCover ? ( + + Cover + + ) : null} + + {item.type} + +
+ {!isCover ? ( + + ) : 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() {
{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 08/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 09/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 {`Photo ) : ( 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 {`Photo )} @@ -101,8 +106,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Média )} @@ -179,7 +187,11 @@ export function CarbetGallery({ title, media }: Props) { // eslint-disable-next-line @next/next/no-img-element {`Photo )} 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 {`${carbet.title} )} 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 {c.title} ) : ( 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 + {alt} + ); +} 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 10/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 11/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!{)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 12/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" ? ( -
+
+

+ 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}

+
+ +
+ + @@ -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 ( + + ); + })} +
+
+ +
+ É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 ( + + ); + })} +
+
+ + +
É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 14/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 */} +
+

+ Critères opérationnels +

+

+ Les 4 dealbreakers d'un séjour en carbet guyanais. Indispensable pour les filtres recherche. +

+
+ + + + + + + + + + + + + + + +
+
+ {/* 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. +

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

+ 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 15/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 ( +
+
+ + {confirmDelete ? ( +
+ 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 ( +
+
+
+ + ← Tous les items + +

+ {item.name} + +

+

+ {RENTAL_CATEGORY_LABEL[item.category]} ·{" "} + + {item.provider.name} + + {item.provider.isSystemD ? " (System D)" : ""} +

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