diff --git a/src/app/connexion/page.tsx b/src/app/connexion/page.tsx
index e66082e..5ac18e4 100644
--- a/src/app/connexion/page.tsx
+++ b/src/app/connexion/page.tsx
@@ -53,6 +53,11 @@ export default async function SignInPage({ searchParams }: Props) {
>
Se connecter
+
+
+ Mot de passe oublié ?
+
+
Pas encore de compte ?{" "}
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 [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 goTo = useCallback(
+ (next: number, animated = true) => {
+ const clamped = ((next % total) + total) % total;
+ setTransitioning(animated);
+ setMediaIndex(clamped);
+ setDragX(0);
+ },
+ [total],
+ );
+
+ 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(() => {
+ 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);
+ };
+ }, []);
+
+ // 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(() => goTo(0, false));
+ }, [isActive, goTo]);
+
+ // 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];
+ drag.current = {
+ startX: t.clientX,
+ startY: t.clientY,
+ startTime: Date.now(),
+ locked: null,
+ };
+ setTransitioning(false);
+ }
+
+ 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;
+ if (navigator.share) {
+ navigator.share({ title, url }).catch(() => {});
+ } else {
+ navigator.clipboard?.writeText(url).catch(() => {});
+ }
+ }, [carbet.slug, carbet.title]);
+
+ if (!current) return null;
+
+ const offsetPct = -mediaIndex * 100;
+
+ return (
+
+ {/* Track : tous les médias en ligne, transformX selon index + drag */}
+
setTransitioning(false)}
+ >
+ {carbet.media.map((m, idx) => {
+ const visible = preloadIndexes.has(idx) || shouldPreload;
+ return (
+
+ {m.type === "VIDEO" ? (
+
{
+ if (el) videoRefs.current.set(idx, el);
+ else videoRefs.current.delete(idx);
+ }}
+ src={visible ? m.url : undefined}
+ muted={muted}
+ playsInline
+ loop
+ preload={visible ? "auto" : "none"}
+ className="h-full w-full object-cover"
+ />
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ );
+ })}
+
+
+ {/* Voile dégradé en bas pour lisibilité */}
+
+
+ {/* Indicateurs progression médias (sticks en haut) */}
+ {total > 1 ? (
+
+ {carbet.media.map((_, i) => {
+ const isActiveStick = i === mediaIndex;
+ const wasSeen = i < mediaIndex;
+ // Progression visuelle pendant le drag (preview du swipe)
+ const progress = isActiveStick && Math.abs(dragX) > 0 && containerWidth > 0
+ ? Math.min(1, Math.abs(dragX) / containerWidth)
+ : 0;
+ return (
+
+ 0 ? { width: `${progress * 100}%` } : undefined}
+ />
+
+ );
+ })}
+
+ ) : null}
+
+ {/* Zones tap horizontales (50/50) sur desktop */}
+
+
+
+ {/* Sidebar boutons droite (mobile) */}
+
+
+
+
+
+
+
+ Favori
+
+
+
+
+
+
+
+
+
+
+
+
+ Partager
+
+
+ {current.type === "VIDEO" ? (
+
setMuted((m) => !m)}
+ className="flex flex-col items-center text-white"
+ aria-label={muted ? "Activer le son" : "Couper le son"}
+ >
+
+ {muted ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+ ) : null}
+
+
+ {/* Bloc info bas + CTAs */}
+
+
+
{carbet.title}
+ {carbet.averageRating !== null ? (
+
+ ★ {carbet.averageRating.toFixed(1)} ({carbet.reviewCount})
+
+ ) : null}
+
+
+ 📍 {carbet.river}
+ ·
+ 👥 jusqu'à {carbet.capacity}
+ ·
+ {Number(carbet.nightlyPrice).toFixed(0)} € / nuit
+
+
+
+ Voir la fiche
+
+
+ Réserver
+
+
+
+
+ );
+}
diff --git a/src/app/decouvrir/_components/ReelsViewer.tsx b/src/app/decouvrir/_components/ReelsViewer.tsx
new file mode 100644
index 0000000..aec37ce
--- /dev/null
+++ b/src/app/decouvrir/_components/ReelsViewer.tsx
@@ -0,0 +1,158 @@
+"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}
+
+
+ {/* Logo Karbé en surimpression haut centre */}
+
+ Karbé
+
+
+
+ {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/_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 ?
+
+ Oui
+
+ setConfirmReject(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+ <>
+
+ Confirmer
+
+
setConfirmReject(true)}
+ disabled={pending}
+ className="rounded border border-rose-300 bg-white px-2.5 py-1 text-[11px] font-semibold text-rose-700 hover:bg-rose-50 disabled:opacity-50"
+ >
+ 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/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx
index 93768b1..39ae0f9 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,
@@ -33,10 +32,14 @@ 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" },
- 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 } } } },
},
@@ -55,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),
};
@@ -80,14 +87,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/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({