Merge pull request 'feat: espace hôte dashboard + lightbox galerie' (#59) from feat/host-dashboard-and-lightbox into main
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
tarzzan 2026-06-01 16:16:27 +00:00
commit d1a1bb04de
4 changed files with 527 additions and 50 deletions

View file

@ -1,3 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { PublicCarbetMedia } from "@/lib/carbet-public";
import { MediaType } from "@/generated/prisma/enums";
@ -6,9 +10,36 @@ type Props = {
media: PublicCarbetMedia[];
};
// SSR-friendly gallery: shows a cover (photo or video) plus a strip of
// secondary media. No client component — all native HTML controls.
/**
* Galerie publique : grille de vignettes ; clic = lightbox plein écran avec
* navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe.
*/
export function CarbetGallery({ title, media }: Props) {
const [active, setActive] = useState<number | null>(null);
const close = useCallback(() => setActive(null), []);
const next = useCallback(() => {
setActive((i) => (i === null ? null : (i + 1) % media.length));
}, [media.length]);
const prev = useCallback(() => {
setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length));
}, [media.length]);
useEffect(() => {
if (active === null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
}
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [active, close, next, prev]);
if (media.length === 0) {
return (
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
@ -17,57 +48,148 @@ export function CarbetGallery({ title, media }: Props) {
);
}
const [cover, ...rest] = media;
const cover = media[0];
const rest = media.slice(1);
const current = active === null ? null : media[active];
return (
<div className="space-y-3">
<figure className="overflow-hidden rounded-lg bg-zinc-100">
{cover.type === MediaType.VIDEO ? (
<video
src={cover.url}
controls
playsInline
preload="metadata"
className="aspect-[16/9] w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover.url}
alt={`Photo principale de ${title}`}
className="aspect-[16/9] w-full object-cover"
/>
)}
</figure>
<>
<div className="space-y-3">
<button
type="button"
onClick={() => setActive(0)}
className="block w-full overflow-hidden rounded-lg bg-zinc-100 transition hover:opacity-95"
aria-label="Ouvrir la photo principale en grand"
>
{cover.type === MediaType.VIDEO ? (
<video
src={cover.url}
controls
playsInline
preload="metadata"
className="aspect-[16/9] w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover.url}
alt={`Photo principale de ${title}`}
className="aspect-[16/9] w-full cursor-zoom-in object-cover"
/>
)}
</button>
{rest.length > 0 ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{rest.map((item) => (
<li
key={item.id}
className="overflow-hidden rounded-md bg-zinc-100"
>
{item.type === MediaType.VIDEO ? (
<video
src={item.url}
preload="metadata"
controls
playsInline
className="aspect-square w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
alt={`Média de ${title}`}
loading="lazy"
className="aspect-square w-full object-cover"
/>
)}
</li>
))}
</ul>
{rest.length > 0 ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{rest.map((item, idx) => (
<li key={item.id} className="overflow-hidden rounded-md bg-zinc-100">
<button
type="button"
onClick={() => setActive(idx + 1)}
className="block w-full"
aria-label="Ouvrir en grand"
>
{item.type === MediaType.VIDEO ? (
<video
src={item.url}
preload="metadata"
controls
playsInline
className="aspect-square w-full bg-black object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
alt={`Média de ${title}`}
loading="lazy"
className="aspect-square w-full cursor-zoom-in object-cover transition hover:scale-105"
/>
)}
</button>
</li>
))}
</ul>
) : null}
</div>
{current ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm"
onClick={close}
role="dialog"
aria-modal="true"
aria-label="Galerie photo"
>
<button
type="button"
onClick={close}
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
aria-label="Fermer"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6 L18 18 M6 18 L18 6" />
</svg>
</button>
{media.length > 1 ? (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
prev();
}}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
aria-label="Précédent"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 6 L9 12 L15 18" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
next();
}}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
aria-label="Suivant"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 6 L15 12 L9 18" />
</svg>
</button>
</>
) : null}
<div
className="max-h-[88vh] max-w-[92vw]"
onClick={(e) => e.stopPropagation()}
>
{current.type === MediaType.VIDEO ? (
<video
src={current.url}
controls
autoPlay
playsInline
className="max-h-[88vh] max-w-[92vw] object-contain"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.url}
alt={`Photo ${active! + 1} sur ${media.length} de ${title}`}
className="max-h-[88vh] max-w-[92vw] object-contain"
/>
)}
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
{active! + 1} / {media.length}
</div>
</div>
) : null}
</div>
</>
);
}

View file

@ -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<string | null>(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 (
<div className="flex flex-wrap items-center gap-1.5">
{confirmReject ? (
<div className="flex items-center gap-1.5 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Refuser ?</span>
<button
type="button"
onClick={reject}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmReject(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<>
<button
type="button"
onClick={accept}
disabled={pending}
className="rounded bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Confirmer
</button>
<button
type="button"
onClick={() => 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
</button>
</>
)}
{error ? <span className="text-[11px] text-rose-700">{error}</span> : null}
</div>
);
}

View file

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

203
src/lib/host-dashboard.ts Normal file
View file

@ -0,0 +1,203 @@
import "server-only";
import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export type HostKpis = {
revenueTotal: string;
revenue30d: string;
revenue365d: string;
bookingsPending: number;
bookingsConfirmedUpcoming: number;
bookingsTotal: number;
carbetsCount: number;
carbetsPublished: number;
occupancyRate30d: number; // 0..1
nextArrival: { id: string; carbetTitle: string; startDate: Date; tenantName: string } | null;
};
type Scope = { ownerId: string; isAdmin: boolean };
function scopeWhere(scope: Scope) {
return scope.isAdmin ? {} : { carbet: { ownerId: scope.ownerId } };
}
function carbetWhere(scope: Scope) {
return scope.isAdmin ? {} : { ownerId: scope.ownerId };
}
export async function getHostKpis(scope: Scope): Promise<HostKpis> {
const now = new Date();
const last30 = new Date(now.getTime() - 30 * 86_400_000);
const last365 = new Date(now.getTime() - 365 * 86_400_000);
const [revAll, rev30, rev365, pending, upcomingConfirmed, total, carbetsTotal, carbetsPub, nextArrival] =
await Promise.all([
prisma.booking.aggregate({
where: {
...scopeWhere(scope),
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] },
},
_sum: { amount: true },
}),
prisma.booking.aggregate({
where: {
...scopeWhere(scope),
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] },
createdAt: { gte: last30 },
},
_sum: { amount: true },
}),
prisma.booking.aggregate({
where: {
...scopeWhere(scope),
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] },
createdAt: { gte: last365 },
},
_sum: { amount: true },
}),
prisma.booking.count({
where: { ...scopeWhere(scope), status: BookingStatus.PENDING },
}),
prisma.booking.count({
where: {
...scopeWhere(scope),
status: BookingStatus.CONFIRMED,
startDate: { gte: now },
},
}),
prisma.booking.count({ where: scopeWhere(scope) }),
prisma.carbet.count({ where: carbetWhere(scope) }),
prisma.carbet.count({ where: { ...carbetWhere(scope), status: "PUBLISHED" } }),
prisma.booking.findFirst({
where: {
...scopeWhere(scope),
status: BookingStatus.CONFIRMED,
startDate: { gte: now },
},
orderBy: { startDate: "asc" },
select: {
id: true,
startDate: true,
carbet: { select: { title: true } },
tenant: { select: { firstName: true, lastName: true } },
},
}),
]);
// Taux d'occupation 30j : nuits réservées / (carbets publiés × 30)
const occupiedNights = await prisma.booking.findMany({
where: {
...scopeWhere(scope),
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
startDate: { lt: now },
endDate: { gte: last30 },
},
select: { startDate: true, endDate: true },
});
let totalNightsOccupied = 0;
for (const b of occupiedNights) {
const s = Math.max(b.startDate.getTime(), last30.getTime());
const e = Math.min(b.endDate.getTime(), now.getTime());
if (e > s) totalNightsOccupied += Math.floor((e - s) / 86_400_000);
}
const denom = Math.max(1, carbetsPub * 30);
const occupancyRate30d = Math.min(1, totalNightsOccupied / denom);
return {
revenueTotal: (revAll._sum.amount ?? 0).toString(),
revenue30d: (rev30._sum.amount ?? 0).toString(),
revenue365d: (rev365._sum.amount ?? 0).toString(),
bookingsPending: pending,
bookingsConfirmedUpcoming: upcomingConfirmed,
bookingsTotal: total,
carbetsCount: carbetsTotal,
carbetsPublished: carbetsPub,
occupancyRate30d,
nextArrival: nextArrival
? {
id: nextArrival.id,
carbetTitle: nextArrival.carbet.title,
startDate: nextArrival.startDate,
tenantName: `${nextArrival.tenant.firstName} ${nextArrival.tenant.lastName}`.trim(),
}
: null,
};
}
export type HostRecentBooking = {
id: string;
carbetId: string;
carbetTitle: string;
carbetSlug: string;
tenantName: string;
startDate: Date;
endDate: Date;
guestCount: number;
status: BookingStatus;
paymentStatus: PaymentStatus;
amount: string;
currency: string;
};
export async function listHostRecentBookings(
scope: Scope,
limit = 10,
): Promise<HostRecentBooking[]> {
const rows = await prisma.booking.findMany({
where: scopeWhere(scope),
orderBy: [{ status: "asc" }, { createdAt: "desc" }],
take: limit,
select: {
id: true,
startDate: true,
endDate: true,
guestCount: true,
status: true,
paymentStatus: true,
amount: true,
currency: true,
carbet: { select: { id: true, title: true, slug: true } },
tenant: { select: { firstName: true, lastName: true } },
},
});
return rows.map((r) => ({
id: r.id,
carbetId: r.carbet.id,
carbetTitle: r.carbet.title,
carbetSlug: r.carbet.slug,
tenantName: `${r.tenant.firstName} ${r.tenant.lastName}`.trim(),
startDate: r.startDate,
endDate: r.endDate,
guestCount: r.guestCount,
status: r.status,
paymentStatus: r.paymentStatus,
amount: r.amount.toString(),
currency: r.currency,
}));
}
export async function listHostCarbets(scope: Scope) {
const rows = await prisma.carbet.findMany({
where: carbetWhere(scope),
orderBy: [{ updatedAt: "desc" }],
select: {
id: true,
slug: true,
title: true,
status: true,
nightlyPrice: true,
capacity: true,
river: true,
_count: { select: { bookings: true, reviews: true, media: true } },
},
});
return rows.map((r) => ({ ...r, nightlyPrice: r.nightlyPrice.toString() }));
}
export function isScopeAdmin(role: UserRole | string | undefined): boolean {
return role === UserRole.ADMIN;
}