diff --git a/src/app/connexion/page.tsx b/src/app/connexion/page.tsx
index 5ac18e4..e66082e 100644
--- a/src/app/connexion/page.tsx
+++ b/src/app/connexion/page.tsx
@@ -53,11 +53,6 @@ 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
deleted file mode 100644
index aec37ce..0000000
--- a/src/app/decouvrir/_components/ReelsViewer.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-"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
deleted file mode 100644
index ed232bf..0000000
--- a/src/app/decouvrir/page.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-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
deleted file mode 100644
index 9380ea2..0000000
--- a/src/app/espace-hote/_components/BookingDecision.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"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
deleted file mode 100644
index fa9208c..0000000
--- a/src/app/espace-hote/actions.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-"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 39ae0f9..93768b1 100644
--- a/src/app/espace-hote/carbets/[carbetId]/page.tsx
+++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx
@@ -3,10 +3,11 @@ import { notFound } from "next/navigation";
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
import { prisma } from "@/lib/prisma";
-import { MediaUploader } from "@/components/MediaUploader";
+import { isStorageConfigured } from "@/lib/storage";
import { updateCarbet } from "../actions";
import { CarbetForm } from "../_components/carbet-form";
+import { MediaManager } from "../_components/media-manager";
export default async function EditCarbetPage({
params,
@@ -32,14 +33,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" },
- select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ select: { id: true, type: true, s3Url: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
},
@@ -58,10 +55,6 @@ 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),
};
@@ -87,10 +80,14 @@ export default async function EditCarbetPage({
Médias
- 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.
+ Le premier média sert de photo de couverture. Réordonnez avec les
+ flèches.
-
+
diff --git a/src/app/espace-hote/carbets/_components/carbet-form.tsx b/src/app/espace-hote/carbets/_components/carbet-form.tsx
index 3a484c6..ac2d234 100644
--- a/src/app/espace-hote/carbets/_components/carbet-form.tsx
+++ b/src/app/espace-hote/carbets/_components/carbet-form.tsx
@@ -17,10 +17,6 @@ export type CarbetFormDefaults = {
embarkPoint: string;
pirogueDurationMin: string;
capacity: string;
- roadAccess: string;
- electricity: string;
- gsmAtCarbet: boolean;
- gsmExitDistanceKm: string;
status: CarbetStatus;
amenityKeys: string[];
};
@@ -220,90 +216,6 @@ export function CarbetForm({