From 75159813363ecb9b825ab4d45f1832dc02ef13f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karb=C3=A9=20Frontend?= Date: Sat, 30 May 2026 15:08:55 +0000 Subject: [PATCH] feat(reviews): avis & notes carbet (SYS-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/bookings/[bookingId]/review/route.ts | 112 +++++++++++++++ .../api/carbets/[carbetId]/reviews/route.ts | 39 +++++ .../api/reviews/[reviewId]/response/route.ts | 85 +++++++++++ src/app/carbets/[slug]/page.tsx | 34 ++++- src/app/carbets/_components/carbet-card.tsx | 12 ++ .../_components/host-response-form.tsx | 69 +++++++++ src/app/carbets/_components/review-form.tsx | 111 ++++++++++++++ .../carbets/_components/reviews-section.tsx | 104 ++++++++++++++ src/app/carbets/_components/star-rating.tsx | 36 +++++ src/app/mes-reservations/page.tsx | 135 ++++++++++++++++++ src/lib/carbet-public.ts | 17 +++ src/lib/carbet-search.ts | 37 +++-- src/lib/reviews-server.ts | 88 ++++++++++++ src/lib/reviews.ts | 38 +++++ 14 files changed, 903 insertions(+), 14 deletions(-) create mode 100644 src/app/api/bookings/[bookingId]/review/route.ts create mode 100644 src/app/api/carbets/[carbetId]/reviews/route.ts create mode 100644 src/app/api/reviews/[reviewId]/response/route.ts create mode 100644 src/app/carbets/_components/host-response-form.tsx create mode 100644 src/app/carbets/_components/review-form.tsx create mode 100644 src/app/carbets/_components/reviews-section.tsx create mode 100644 src/app/carbets/_components/star-rating.tsx create mode 100644 src/app/mes-reservations/page.tsx create mode 100644 src/lib/reviews-server.ts create mode 100644 src/lib/reviews.ts diff --git a/src/app/api/bookings/[bookingId]/review/route.ts b/src/app/api/bookings/[bookingId]/review/route.ts new file mode 100644 index 0000000..62fe319 --- /dev/null +++ b/src/app/api/bookings/[bookingId]/review/route.ts @@ -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 }); +} diff --git a/src/app/api/carbets/[carbetId]/reviews/route.ts b/src/app/api/carbets/[carbetId]/reviews/route.ts new file mode 100644 index 0000000..6146f8f --- /dev/null +++ b/src/app/api/carbets/[carbetId]/reviews/route.ts @@ -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 }); +} diff --git a/src/app/api/reviews/[reviewId]/response/route.ts b/src/app/api/reviews/[reviewId]/response/route.ts new file mode 100644 index 0000000..362e833 --- /dev/null +++ b/src/app/api/reviews/[reviewId]/response/route.ts @@ -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 }); +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index cf0ed79..ae88cad 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -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 (
+ {carbet.reviewStats.count > 0 && + carbet.reviewStats.averageRating !== null ? ( +

+ + + {formatAverageRating(carbet.reviewStats.averageRating)} + + + · {carbet.reviewStats.count} avis + +

+ ) : null}
@@ -166,6 +190,12 @@ export default async function PublicCarbetPage({ params }: PageProps) {

+ +
); } diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index 0667ccf..feecf82 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -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" : ""}

+ {carbet.reviewCount > 0 && carbet.averageRating !== null ? ( +

+ + + {formatAverageRating(carbet.averageRating)} + + ({carbet.reviewCount}) +

+ ) : null}

{truncate(carbet.description, 180)}

diff --git a/src/app/carbets/_components/host-response-form.tsx b/src/app/carbets/_components/host-response-form.tsx new file mode 100644 index 0000000..2d24a0b --- /dev/null +++ b/src/app/carbets/_components/host-response-form.tsx @@ -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(null); + const [pending, setPending] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + 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 ( +
+