feat(reviews): avis & notes carbet (SYS-8)
- Lib reviews: constants/types (client-safe) + DB helpers (server-only) - API POST /api/bookings/[bookingId]/review : avis locataire après séjour COMPLETED - API POST /api/reviews/[reviewId]/response : réponse loueur - API GET /api/carbets/[carbetId]/reviews : liste + stats agrégées - Fiche carbet : note moyenne + nombre d'avis + liste avec réponses loueur - Carte carbet : étoiles + note moyenne + compteur - /mes-reservations : formulaire d'avis pour les séjours terminés du locataire
This commit is contained in:
parent
f0d6cdf46c
commit
7515981336
14 changed files with 903 additions and 14 deletions
112
src/app/api/bookings/[bookingId]/review/route.ts
Normal file
112
src/app/api/bookings/[bookingId]/review/route.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { BookingStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
REVIEW_COMMENT_MAX,
|
||||
REVIEW_RATING_MAX,
|
||||
REVIEW_RATING_MIN,
|
||||
isValidRating,
|
||||
} from "@/lib/reviews";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type CreateReviewBody = {
|
||||
rating?: number;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ bookingId: string }> },
|
||||
) {
|
||||
const { bookingId } = await params;
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: CreateReviewBody;
|
||||
try {
|
||||
body = (await request.json()) as CreateReviewBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const rating = Number(body.rating);
|
||||
if (!isValidRating(rating)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `La note doit être un entier entre ${REVIEW_RATING_MIN} et ${REVIEW_RATING_MAX}.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const comment =
|
||||
typeof body.comment === "string" ? body.comment.trim() : "";
|
||||
if (comment.length > REVIEW_COMMENT_MAX) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Le commentaire est limité à ${REVIEW_COMMENT_MAX} caractères.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
id: true,
|
||||
carbetId: true,
|
||||
tenantId: true,
|
||||
status: true,
|
||||
review: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (booking.tenantId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Seul le locataire peut laisser un avis." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (booking.status !== BookingStatus.COMPLETED) {
|
||||
return NextResponse.json(
|
||||
{ error: "Un avis ne peut être laissé qu'après un séjour terminé." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (booking.review) {
|
||||
return NextResponse.json(
|
||||
{ error: "Un avis a déjà été déposé pour cette réservation." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const review = await prisma.review.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
carbetId: booking.carbetId,
|
||||
authorId: session.user.id,
|
||||
rating,
|
||||
comment: comment.length > 0 ? comment : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
comment: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ review }, { status: 201 });
|
||||
}
|
||||
39
src/app/api/carbets/[carbetId]/reviews/route.ts
Normal file
39
src/app/api/carbets/[carbetId]/reviews/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getCarbetReviewStats, listCarbetReviews } from "@/lib/reviews-server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_LIMIT = 20;
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ carbetId: string }> },
|
||||
) {
|
||||
const { carbetId } = await params;
|
||||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!carbet) {
|
||||
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
const limitRaw = request.nextUrl.searchParams.get("limit");
|
||||
const parsedLimit = Number(limitRaw);
|
||||
const limit =
|
||||
Number.isInteger(parsedLimit) && parsedLimit > 0
|
||||
? Math.min(parsedLimit, MAX_LIMIT)
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const [stats, reviews] = await Promise.all([
|
||||
getCarbetReviewStats(carbetId),
|
||||
listCarbetReviews(carbetId, limit),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ stats, reviews });
|
||||
}
|
||||
85
src/app/api/reviews/[reviewId]/response/route.ts
Normal file
85
src/app/api/reviews/[reviewId]/response/route.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { REVIEW_HOST_RESPONSE_MAX } from "@/lib/reviews";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type HostResponseBody = {
|
||||
response?: string;
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ reviewId: string }> },
|
||||
) {
|
||||
const { reviewId } = await params;
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: HostResponseBody;
|
||||
try {
|
||||
body = (await request.json()) as HostResponseBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const response =
|
||||
typeof body.response === "string" ? body.response.trim() : "";
|
||||
if (response.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "La réponse est requise." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (response.length > REVIEW_HOST_RESPONSE_MAX) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `La réponse est limitée à ${REVIEW_HOST_RESPONSE_MAX} caractères.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const review = await prisma.review.findUnique({
|
||||
where: { id: reviewId },
|
||||
select: {
|
||||
id: true,
|
||||
hostResponse: true,
|
||||
carbet: { select: { ownerId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return NextResponse.json({ error: "Avis introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = review.carbet.ownerId === session.user.id;
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: "Seul le loueur peut répondre à cet avis." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.review.update({
|
||||
where: { id: reviewId },
|
||||
data: {
|
||||
hostResponse: response,
|
||||
hostRespondedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hostResponse: true,
|
||||
hostRespondedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ review: updated }, { status: 200 });
|
||||
}
|
||||
|
|
@ -2,15 +2,19 @@ import type { Metadata } from "next";
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { getPublicCarbet } from "@/lib/carbet-public";
|
||||
import {
|
||||
formatCoordinate,
|
||||
formatPirogueDuration,
|
||||
truncate,
|
||||
} from "@/lib/format";
|
||||
import { MediaType } from "@/generated/prisma/enums";
|
||||
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
||||
import { formatAverageRating } from "@/lib/reviews";
|
||||
|
||||
import { CarbetGallery } from "../_components/carbet-gallery";
|
||||
import { ReviewsSection } from "../_components/reviews-section";
|
||||
import { StarRating } from "../_components/star-rating";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -60,12 +64,20 @@ export async function generateMetadata({
|
|||
|
||||
export default async function PublicCarbetPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const carbet = await getPublicCarbet(slug);
|
||||
const [carbet, session] = await Promise.all([
|
||||
getPublicCarbet(slug),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
if (!carbet) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const viewerId = session?.user?.id ?? null;
|
||||
const isViewerOwner =
|
||||
viewerId !== null &&
|
||||
(viewerId === carbet.ownerId || session?.user?.role === UserRole.ADMIN);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
||||
<Link
|
||||
|
|
@ -88,6 +100,18 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
{formatPirogueDuration(carbet.pirogueDurationMin)} depuis{" "}
|
||||
{carbet.embarkPoint}
|
||||
</p>
|
||||
{carbet.reviewStats.count > 0 &&
|
||||
carbet.reviewStats.averageRating !== null ? (
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">
|
||||
<StarRating value={carbet.reviewStats.averageRating} />
|
||||
<span className="font-medium">
|
||||
{formatAverageRating(carbet.reviewStats.averageRating)}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
· {carbet.reviewStats.count} avis
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="mt-6">
|
||||
|
|
@ -166,6 +190,12 @@ export default async function PublicCarbetPage({ params }: PageProps) {
|
|||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ReviewsSection
|
||||
stats={carbet.reviewStats}
|
||||
reviews={carbet.reviews}
|
||||
isOwner={isViewerOwner}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import Link from "next/link";
|
|||
|
||||
import type { CarbetSearchResult } from "@/lib/carbet-search";
|
||||
import { formatPirogueDuration, truncate } from "@/lib/format";
|
||||
import { formatAverageRating } from "@/lib/reviews";
|
||||
|
||||
import { StarRating } from "./star-rating";
|
||||
|
||||
export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
|
||||
const href = `/carbets/${carbet.slug}`;
|
||||
|
|
@ -34,6 +37,15 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {
|
|||
Fleuve {carbet.river} · {carbet.capacity} voyageur
|
||||
{carbet.capacity > 1 ? "s" : ""}
|
||||
</p>
|
||||
{carbet.reviewCount > 0 && carbet.averageRating !== null ? (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-zinc-600">
|
||||
<StarRating value={carbet.averageRating} className="text-sm" />
|
||||
<span className="font-medium text-zinc-700">
|
||||
{formatAverageRating(carbet.averageRating)}
|
||||
</span>
|
||||
<span className="text-zinc-500">({carbet.reviewCount})</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 line-clamp-3 text-sm text-zinc-600">
|
||||
{truncate(carbet.description, 180)}
|
||||
</p>
|
||||
|
|
|
|||
69
src/app/carbets/_components/host-response-form.tsx
Normal file
69
src/app/carbets/_components/host-response-form.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { REVIEW_HOST_RESPONSE_MAX } from "@/lib/reviews";
|
||||
|
||||
export function HostResponseForm({ reviewId }: { reviewId: string }) {
|
||||
const router = useRouter();
|
||||
const [response, setResponse] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const trimmed = response.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError("Veuillez saisir une réponse.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/reviews/${reviewId}/response`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: trimmed }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? "Impossible d'envoyer la réponse.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Erreur réseau, veuillez réessayer.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-3 space-y-2">
|
||||
<label className="block text-xs font-medium text-zinc-600">
|
||||
Répondre en tant que loueur
|
||||
<textarea
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
maxLength={REVIEW_HOST_RESPONSE_MAX}
|
||||
rows={3}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-800 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="Merci pour votre séjour…"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="inline-flex items-center rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Envoi…" : "Publier la réponse"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
111
src/app/carbets/_components/review-form.tsx
Normal file
111
src/app/carbets/_components/review-form.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
REVIEW_COMMENT_MAX,
|
||||
REVIEW_RATING_MAX,
|
||||
REVIEW_RATING_MIN,
|
||||
} from "@/lib/reviews";
|
||||
|
||||
type ReviewFormProps = {
|
||||
bookingId: string;
|
||||
};
|
||||
|
||||
export function ReviewForm({ bookingId }: ReviewFormProps) {
|
||||
const router = useRouter();
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
const [hover, setHover] = useState<number>(0);
|
||||
const [comment, setComment] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (rating < REVIEW_RATING_MIN || rating > REVIEW_RATING_MAX) {
|
||||
setError("Veuillez choisir une note entre 1 et 5.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/bookings/${bookingId}/review`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rating, comment: comment.trim() }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? "Impossible d'envoyer votre avis.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Erreur réseau, veuillez réessayer.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const display = hover || rating;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-3 space-y-3 rounded-md border border-zinc-200 bg-zinc-50 p-4"
|
||||
>
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-zinc-800">Votre note</legend>
|
||||
<div
|
||||
className="mt-1 flex items-center gap-1"
|
||||
onMouseLeave={() => setHover(0)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
type="button"
|
||||
key={star}
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHover(star)}
|
||||
aria-label={`${star} étoile${star > 1 ? "s" : ""}`}
|
||||
className={`text-2xl leading-none ${
|
||||
star <= display ? "text-amber-500" : "text-zinc-300"
|
||||
} hover:scale-110 transition`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-2 text-xs text-zinc-500">
|
||||
{display > 0 ? `${display}/5` : "Cliquez pour noter"}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="block text-sm font-medium text-zinc-800">
|
||||
Commentaire (optionnel)
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxLength={REVIEW_COMMENT_MAX}
|
||||
rows={4}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-800 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="Racontez votre séjour…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || rating === 0}
|
||||
className="inline-flex items-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Envoi…" : "Publier mon avis"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
104
src/app/carbets/_components/reviews-section.tsx
Normal file
104
src/app/carbets/_components/reviews-section.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
|
||||
import { formatAverageRating } from "@/lib/reviews";
|
||||
|
||||
import { HostResponseForm } from "./host-response-form";
|
||||
import { StarRating } from "./star-rating";
|
||||
|
||||
type ReviewsSectionProps = {
|
||||
stats: CarbetReviewStats;
|
||||
reviews: CarbetReview[];
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
const stayFormatter = new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatStay(start: string, end: string): string {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
return `${stayFormatter.format(startDate)} → ${stayFormatter.format(endDate)}`;
|
||||
}
|
||||
|
||||
export function ReviewsSection({ stats, reviews, isOwner }: ReviewsSectionProps) {
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<header className="flex flex-wrap items-baseline gap-3">
|
||||
<h2 className="text-xl font-semibold text-zinc-900">Avis des voyageurs</h2>
|
||||
{stats.count > 0 && stats.averageRating !== null ? (
|
||||
<p className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<StarRating value={stats.averageRating} />
|
||||
<span className="font-medium">
|
||||
{formatAverageRating(stats.averageRating)}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
· {stats.count} avis
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Aucun avis pour le moment.</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{reviews.length > 0 ? (
|
||||
<ul className="mt-5 space-y-5">
|
||||
{reviews.map((review) => (
|
||||
<li
|
||||
key={review.id}
|
||||
className="rounded-lg border border-zinc-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-900">
|
||||
{review.authorFirstName}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{dateFormatter.format(new Date(review.createdAt))} ·{" "}
|
||||
Séjour {formatStay(review.stayStartDate, review.stayEndDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<StarRating value={review.rating} />
|
||||
<span className="font-medium text-zinc-700">
|
||||
{review.rating}/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{review.comment ? (
|
||||
<p className="mt-3 whitespace-pre-line text-sm text-zinc-700">
|
||||
{review.comment}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{review.hostResponse ? (
|
||||
<div className="mt-4 rounded-md border-l-2 border-emerald-400 bg-emerald-50/60 px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-800">
|
||||
Réponse du loueur
|
||||
{review.hostRespondedAt ? (
|
||||
<span className="ml-2 font-normal normal-case text-emerald-700/80">
|
||||
· {dateFormatter.format(new Date(review.hostRespondedAt))}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-line text-sm text-emerald-900">
|
||||
{review.hostResponse}
|
||||
</p>
|
||||
</div>
|
||||
) : isOwner ? (
|
||||
<HostResponseForm reviewId={review.id} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
36
src/app/carbets/_components/star-rating.tsx
Normal file
36
src/app/carbets/_components/star-rating.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
type StarRatingProps = {
|
||||
value: number;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Static 5-star display. `value` is a 0–5 rating (decimal allowed for averages).
|
||||
// Filled portion is achieved by overlaying gold stars on top of grey stars and
|
||||
// clipping the overlay by width — works without client JS so it can render on
|
||||
// server components.
|
||||
export function StarRating({ value, ariaLabel, className }: StarRatingProps) {
|
||||
const clamped = Math.min(5, Math.max(0, value));
|
||||
const fillPct = (clamped / 5) * 100;
|
||||
const label = ariaLabel ?? `Note ${clamped.toFixed(1).replace(".", ",")} sur 5`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={["relative inline-block leading-none", className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-label={label}
|
||||
role="img"
|
||||
>
|
||||
<span aria-hidden className="text-zinc-300">
|
||||
★★★★★
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 overflow-hidden text-amber-500"
|
||||
style={{ width: `${fillPct}%` }}
|
||||
>
|
||||
★★★★★
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
135
src/app/mes-reservations/page.tsx
Normal file
135
src/app/mes-reservations/page.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { requireAuth } from "@/lib/authorization";
|
||||
import { BookingStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatAverageRating } from "@/lib/reviews";
|
||||
|
||||
import { ReviewForm } from "../carbets/_components/review-form";
|
||||
import { StarRating } from "../carbets/_components/star-rating";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const statusLabels: Record<BookingStatus, string> = {
|
||||
PENDING: "En attente",
|
||||
CONFIRMED: "Confirmée",
|
||||
CANCELLED: "Annulée",
|
||||
COMPLETED: "Terminée",
|
||||
};
|
||||
|
||||
export default async function MyBookingsPage() {
|
||||
const session = await requireAuth();
|
||||
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: { tenantId: session.user.id },
|
||||
orderBy: [{ startDate: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
status: true,
|
||||
guestCount: true,
|
||||
carbet: { select: { slug: true, title: true, river: true } },
|
||||
review: {
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
comment: true,
|
||||
hostResponse: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-10">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Mes réservations</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
Retrouvez ici vos séjours passés et à venir. Après un séjour terminé,
|
||||
partagez votre expérience en laissant un avis.
|
||||
</p>
|
||||
|
||||
{bookings.length === 0 ? (
|
||||
<p className="mt-10 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-6 text-center text-sm text-zinc-600">
|
||||
Vous n'avez pas encore de réservation.{" "}
|
||||
<Link href="/carbets" className="text-emerald-700 hover:underline">
|
||||
Découvrez les carbets disponibles
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-8 space-y-5">
|
||||
{bookings.map((booking) => {
|
||||
const canReview =
|
||||
booking.status === BookingStatus.COMPLETED && !booking.review;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={booking.id}
|
||||
className="rounded-lg border border-zinc-200 bg-white p-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">
|
||||
<Link
|
||||
href={`/carbets/${booking.carbet.slug}`}
|
||||
className="hover:text-emerald-700"
|
||||
>
|
||||
{booking.carbet.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Fleuve {booking.carbet.river}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-700">
|
||||
{statusLabels[booking.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-700">
|
||||
{dateFormatter.format(booking.startDate)} →{" "}
|
||||
{dateFormatter.format(booking.endDate)} · {booking.guestCount}{" "}
|
||||
voyageur{booking.guestCount > 1 ? "s" : ""}
|
||||
</p>
|
||||
|
||||
{booking.review ? (
|
||||
<div className="mt-4 rounded-md border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<StarRating value={booking.review.rating} />
|
||||
<span className="font-medium text-zinc-700">
|
||||
{formatAverageRating(booking.review.rating)}/5
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
— Votre avis
|
||||
</span>
|
||||
</div>
|
||||
{booking.review.comment ? (
|
||||
<p className="mt-2 whitespace-pre-line text-sm text-zinc-700">
|
||||
{booking.review.comment}
|
||||
</p>
|
||||
) : null}
|
||||
{booking.review.hostResponse ? (
|
||||
<p className="mt-2 text-xs text-emerald-800">
|
||||
Le loueur vous a répondu.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReview ? <ReviewForm bookingId={booking.id} /> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,11 @@ import { cache } from "react";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import { amenityLabel } from "@/lib/amenities";
|
||||
import { CarbetStatus, MediaType } from "@/generated/prisma/enums";
|
||||
import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
|
||||
import {
|
||||
getCarbetReviewStats,
|
||||
listCarbetReviews,
|
||||
} from "@/lib/reviews-server";
|
||||
|
||||
export type PublicCarbetMedia = {
|
||||
id: string;
|
||||
|
|
@ -21,9 +26,12 @@ export type PublicCarbetDetail = {
|
|||
capacity: number;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
ownerId: string;
|
||||
ownerFirstName: string;
|
||||
media: PublicCarbetMedia[];
|
||||
amenities: { key: string; label: string }[];
|
||||
reviewStats: CarbetReviewStats;
|
||||
reviews: CarbetReview[];
|
||||
};
|
||||
|
||||
// Memoized within a single request so generateMetadata() and the page itself
|
||||
|
|
@ -43,6 +51,7 @@ export const getPublicCarbet = cache(
|
|||
capacity: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
ownerId: true,
|
||||
owner: { select: { firstName: true } },
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
|
|
@ -56,6 +65,11 @@ export const getPublicCarbet = cache(
|
|||
|
||||
if (!carbet) return null;
|
||||
|
||||
const [reviewStats, reviews] = await Promise.all([
|
||||
getCarbetReviewStats(carbet.id),
|
||||
listCarbetReviews(carbet.id, 20),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: carbet.id,
|
||||
slug: carbet.slug,
|
||||
|
|
@ -67,6 +81,7 @@ export const getPublicCarbet = cache(
|
|||
capacity: carbet.capacity,
|
||||
latitude: carbet.latitude.toString(),
|
||||
longitude: carbet.longitude.toString(),
|
||||
ownerId: carbet.ownerId,
|
||||
ownerFirstName: carbet.owner.firstName,
|
||||
media: carbet.media.map((m) => ({
|
||||
id: m.id,
|
||||
|
|
@ -80,6 +95,8 @@ export const getPublicCarbet = cache(
|
|||
label: amenityLabel(entry.amenity.key) || entry.amenity.label,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "fr")),
|
||||
reviewStats,
|
||||
reviews,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
AvailabilityScope,
|
||||
CarbetStatus,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { getCarbetReviewStatsMany } from "@/lib/reviews-server";
|
||||
|
||||
export type CarbetSearchFilters = {
|
||||
river?: string;
|
||||
|
|
@ -73,6 +74,8 @@ export type CarbetSearchResult = {
|
|||
description: string;
|
||||
coverUrl: string | null;
|
||||
mediaCount: number;
|
||||
reviewCount: number;
|
||||
averageRating: number | null;
|
||||
};
|
||||
|
||||
// Build the Prisma where-clause for a public carbet search. A carbet is only
|
||||
|
|
@ -132,18 +135,28 @@ export async function searchCarbets(
|
|||
},
|
||||
});
|
||||
|
||||
return carbets.map((carbet) => ({
|
||||
id: carbet.id,
|
||||
slug: carbet.slug,
|
||||
title: carbet.title,
|
||||
river: carbet.river,
|
||||
embarkPoint: carbet.embarkPoint,
|
||||
pirogueDurationMin: carbet.pirogueDurationMin,
|
||||
capacity: carbet.capacity,
|
||||
description: carbet.description,
|
||||
coverUrl: carbet.media[0]?.s3Url ?? null,
|
||||
mediaCount: carbet._count.media,
|
||||
}));
|
||||
const statsMap = await getCarbetReviewStatsMany(carbets.map((c) => c.id));
|
||||
|
||||
return carbets.map((carbet) => {
|
||||
const stats = statsMap.get(carbet.id) ?? {
|
||||
count: 0,
|
||||
averageRating: null,
|
||||
};
|
||||
return {
|
||||
id: carbet.id,
|
||||
slug: carbet.slug,
|
||||
title: carbet.title,
|
||||
river: carbet.river,
|
||||
embarkPoint: carbet.embarkPoint,
|
||||
pirogueDurationMin: carbet.pirogueDurationMin,
|
||||
capacity: carbet.capacity,
|
||||
description: carbet.description,
|
||||
coverUrl: carbet.media[0]?.s3Url ?? null,
|
||||
mediaCount: carbet._count.media,
|
||||
reviewCount: stats.count,
|
||||
averageRating: stats.averageRating,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Distinct list of rivers across the published catalogue, for filter UI hints.
|
||||
|
|
|
|||
88
src/lib/reviews-server.ts
Normal file
88
src/lib/reviews-server.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Server-only: this module pulls in Prisma. Do not import from client
|
||||
// components — `@/lib/reviews` (constants/types) is the safe surface.
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import type { CarbetReview, CarbetReviewStats } from "@/lib/reviews";
|
||||
|
||||
// Aggregate stats used on cards and detail pages. Returns null average when
|
||||
// there are no reviews so the caller can render a neutral "no reviews yet"
|
||||
// state rather than a misleading 0.0.
|
||||
export async function getCarbetReviewStats(
|
||||
carbetId: string,
|
||||
): Promise<CarbetReviewStats> {
|
||||
const agg = await prisma.review.aggregate({
|
||||
where: { carbetId },
|
||||
_count: { _all: true },
|
||||
_avg: { rating: true },
|
||||
});
|
||||
|
||||
const count = agg._count._all;
|
||||
const avg = agg._avg.rating;
|
||||
|
||||
return {
|
||||
count,
|
||||
averageRating: count > 0 && avg !== null ? Number(avg) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCarbetReviewStatsMany(
|
||||
carbetIds: string[],
|
||||
): Promise<Map<string, CarbetReviewStats>> {
|
||||
if (carbetIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await prisma.review.groupBy({
|
||||
by: ["carbetId"],
|
||||
where: { carbetId: { in: carbetIds } },
|
||||
_count: { _all: true },
|
||||
_avg: { rating: true },
|
||||
});
|
||||
|
||||
const map = new Map<string, CarbetReviewStats>();
|
||||
for (const id of carbetIds) {
|
||||
map.set(id, { count: 0, averageRating: null });
|
||||
}
|
||||
for (const row of rows) {
|
||||
const avg = row._avg.rating;
|
||||
map.set(row.carbetId, {
|
||||
count: row._count._all,
|
||||
averageRating: avg !== null ? Number(avg) : null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function listCarbetReviews(
|
||||
carbetId: string,
|
||||
limit = 20,
|
||||
): Promise<CarbetReview[]> {
|
||||
const reviews = await prisma.review.findMany({
|
||||
where: { carbetId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
comment: true,
|
||||
hostResponse: true,
|
||||
hostRespondedAt: true,
|
||||
createdAt: true,
|
||||
author: { select: { firstName: true } },
|
||||
booking: { select: { startDate: true, endDate: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return reviews.map((review) => ({
|
||||
id: review.id,
|
||||
rating: review.rating,
|
||||
comment: review.comment,
|
||||
hostResponse: review.hostResponse,
|
||||
hostRespondedAt: review.hostRespondedAt?.toISOString() ?? null,
|
||||
createdAt: review.createdAt.toISOString(),
|
||||
authorFirstName: review.author.firstName,
|
||||
stayStartDate: review.booking.startDate.toISOString(),
|
||||
stayEndDate: review.booking.endDate.toISOString(),
|
||||
}));
|
||||
}
|
||||
38
src/lib/reviews.ts
Normal file
38
src/lib/reviews.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Constants, types, and pure helpers used on both server and client. The
|
||||
// database-backed query helpers live in `reviews-server.ts` so that client
|
||||
// components importing the constants don't pull Prisma into the browser bundle.
|
||||
|
||||
export const REVIEW_RATING_MIN = 1;
|
||||
export const REVIEW_RATING_MAX = 5;
|
||||
export const REVIEW_COMMENT_MAX = 2000;
|
||||
export const REVIEW_HOST_RESPONSE_MAX = 2000;
|
||||
|
||||
export type CarbetReviewStats = {
|
||||
count: number;
|
||||
averageRating: number | null;
|
||||
};
|
||||
|
||||
export type CarbetReview = {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
hostResponse: string | null;
|
||||
hostRespondedAt: string | null;
|
||||
createdAt: string;
|
||||
authorFirstName: string;
|
||||
stayStartDate: string;
|
||||
stayEndDate: string;
|
||||
};
|
||||
|
||||
export function isValidRating(value: unknown): value is number {
|
||||
return (
|
||||
Number.isInteger(value) &&
|
||||
(value as number) >= REVIEW_RATING_MIN &&
|
||||
(value as number) <= REVIEW_RATING_MAX
|
||||
);
|
||||
}
|
||||
|
||||
export function formatAverageRating(avg: number | null): string {
|
||||
if (avg === null) return "—";
|
||||
return avg.toFixed(1).replace(".", ",");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue