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