karbe/src/app/decouvrir/_components/ReelSlide.tsx
Claude Integration d5732917e3
All checks were successful
CI / test (pull_request) Successful in 2m16s
feat(reels): swipe horizontal animé avec suivi du doigt + snap
2026-06-02 02:03:23 +00:00

403 lines
14 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, 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;
isActive: boolean;
shouldPreload: boolean;
isFavorite: boolean;
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 [dragX, setDragX] = useState(0);
const [transitioning, setTransitioning] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const videoRefs = useRef<Map<number, HTMLVideoElement>>(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<number>();
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 (
<div className="relative h-full w-full overflow-hidden bg-black">
{/* Track : tous les médias en ligne, transformX selon index + drag */}
<div
ref={containerRef}
className="absolute inset-0 flex"
style={{
width: `${total * 100}%`,
transform: `translateX(calc(${offsetPct / total}% + ${dragX}px))`,
transition: transitioning ? "transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1)" : "none",
touchAction: "pan-y",
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTransitionEnd={() => setTransitioning(false)}
>
{carbet.media.map((m, idx) => {
const visible = preloadIndexes.has(idx) || shouldPreload;
return (
<div
key={m.id}
className="relative flex h-full shrink-0 items-center justify-center"
style={{ width: `${100 / total}%` }}
aria-hidden={idx !== mediaIndex}
>
{m.type === "VIDEO" ? (
<video
ref={(el) => {
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
<img
src={visible ? m.url : undefined}
srcSet={visible ? buildSrcSet(m.url) : undefined}
sizes="(min-width: 768px) 800px, 100vw"
alt={`${carbet.title} — média ${idx + 1}`}
loading={idx === mediaIndex ? "eager" : "lazy"}
fetchPriority={idx === mediaIndex ? "high" : "auto"}
decoding="async"
draggable={false}
className="h-full w-full select-none object-cover"
/>
)}
</div>
);
})}
</div>
{/* Voile dégradé en bas pour lisibilité */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
{/* Indicateurs progression médias (sticks en haut) */}
{total > 1 ? (
<div className="pointer-events-none absolute left-3 right-3 top-12 flex gap-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 (
<span
key={i}
className={
"relative h-0.5 flex-1 overflow-hidden rounded-full " +
(isActiveStick ? "bg-white/30" : wasSeen ? "bg-white/60" : "bg-white/30")
}
>
<span
className={
"absolute inset-y-0 left-0 bg-white " +
(isActiveStick ? "w-full" : wasSeen ? "w-full" : "w-0")
}
style={progress > 0 ? { width: `${progress * 100}%` } : undefined}
/>
</span>
);
})}
</div>
) : null}
{/* Zones tap horizontales (50/50) sur desktop */}
<button
type="button"
onClick={prevMedia}
className="absolute inset-y-0 left-0 z-10 hidden w-1/3 cursor-default md:block"
aria-label="Média précédent"
/>
<button
type="button"
onClick={nextMedia}
className="absolute inset-y-0 right-0 z-10 hidden w-1/3 cursor-default md:block"
aria-label="Média suivant"
/>
{/* Sidebar boutons droite (mobile) */}
<div className="absolute bottom-32 right-3 z-20 flex flex-col items-center gap-4">
<button
type="button"
onClick={onToggleFavorite}
className="flex flex-col items-center text-white"
aria-label={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}
>
<span
className={
"flex h-12 w-12 items-center justify-center rounded-full backdrop-blur transition " +
(isFavorite ? "bg-rose-500/90" : "bg-white/10 hover:bg-white/20")
}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill={isFavorite ? "white" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</span>
<span className="mt-0.5 text-[10px] font-semibold">Favori</span>
</button>
<button
type="button"
onClick={share}
className="flex flex-col items-center text-white"
aria-label="Partager"
>
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="M8.59 13.51 L15.42 17.49" />
<path d="M15.41 6.51 L8.59 10.49" />
</svg>
</span>
<span className="mt-0.5 text-[10px] font-semibold">Partager</span>
</button>
{current.type === "VIDEO" ? (
<button
type="button"
onClick={() => setMuted((m) => !m)}
className="flex flex-col items-center text-white"
aria-label={muted ? "Activer le son" : "Couper le son"}
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
{muted ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
)}
</span>
</button>
) : null}
</div>
{/* Bloc info bas + CTAs */}
<div className="absolute inset-x-0 bottom-0 z-10 p-4 pb-6 text-white">
<div className="mb-2 flex items-baseline gap-2">
<h2 className="text-lg font-semibold">{carbet.title}</h2>
{carbet.averageRating !== null ? (
<span className="text-xs text-white/80">
{carbet.averageRating.toFixed(1)} ({carbet.reviewCount})
</span>
) : null}
</div>
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/80">
<span>📍 {carbet.river}</span>
<span>·</span>
<span>👥 jusqu&apos;à {carbet.capacity}</span>
<span>·</span>
<span className="font-mono font-semibold text-white">{Number(carbet.nightlyPrice).toFixed(0)} / nuit</span>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={`/carbets/${carbet.slug}`}
className="rounded-full bg-white/10 px-4 py-2 text-xs font-semibold backdrop-blur hover:bg-white/20"
>
Voir la fiche
</Link>
<Link
href={`/carbets/${carbet.slug}#reserver`}
className="rounded-full bg-emerald-500 px-4 py-2 text-xs font-semibold hover:bg-emerald-400"
>
Réserver
</Link>
</div>
</div>
</div>
);
}