feat(rental): Sprint D — panier + checkout + intégration carbet
Some checks failed
CI / test (pull_request) Failing after 1m8s

Cart lib + cookie persistence (karbe-rental-cart, 30j) avec context React
useCart(). Provider wrappé dans layout pour hydratation server→client.

Page /panier :
- Récap regroupé par prestataire (sous-totaux, caution)
- Édition lignes (dates, qté), suppression, vider panier
- Bouton « Valider et payer » → POST /api/rentals/checkout
- Badge 🛒 dans SiteHeader avec total items

Composant <AddToCart /> sur /materiel/[itemId] avec date picker + qté.

API POST /api/rentals/checkout :
- Validation auth + items actifs + provider approved + qté/dates
- Transaction Prisma : recheck stock par fenêtre + crée 1 RentalBooking
  par prestataire + RentalLines (snapshot prix) + RentalItemAvailability
  (blocage des dispos)
- Calcul commissionAmount selon provider.commissionPct
- Si Stripe activé : Checkout Session unique avec 1 line_item par
  RentalBooking, metadata {type:"rental-bundle", rentalBookingIds:[]}
- Sinon : crée en PENDING, retourne rentalBookingIds
- Vide le cookie panier après création
- Audit log rental.checkout.created

Webhook Stripe étendu :
- checkout.session.completed type=rental-bundle → CONFIRMED+SUCCEEDED
  sur toutes les RentalBookings du bundle
- payment_intent.payment_failed metadata.rentalBookingIds → CANCELLED
  + supprime les RentalItemAvailability (libère le stock)

Intégration carbet :
- /carbets/[slug] : panneau « Compléter votre séjour » avec items des
  prestataires de la même rivière + System D (recommandation contextuelle)
- /reservations/[id] : section « Matériel associé » listant les
  RentalBookings liées
- /mes-locations : page récap toutes les locations (System D + tiers,
  liées carbet ou standalone)
- Lien « Mes locations » dans SiteHeader

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 08:41:53 +00:00
parent 1165f32a63
commit 91b4d918ea
16 changed files with 1309 additions and 7 deletions

View file

@ -0,0 +1,313 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
getStripeClient,
isStripeConfigured,
toStripeAmountCents,
} from "@/lib/stripe";
export const runtime = "nodejs";
type LineInput = {
itemId: string;
qty: number;
startDate: Date;
endDate: Date;
nights: number;
};
function parseDateOnly(s: string): Date {
return new Date(s + "T00:00:00Z");
}
export async function POST() {
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
}
const jar = await cookies();
const cart = parseCart(jar.get(CART_COOKIE)?.value);
if (cart.items.length === 0) {
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
}
// Charge tous les items du panier
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = await prisma.rentalItem.findMany({
where: { id: { in: itemIds }, active: true },
include: {
provider: {
select: {
id: true,
name: true,
active: true,
approved: true,
commissionPct: true,
isSystemD: true,
},
},
},
});
const itemById = new Map(items.map((i) => [i.id, i]));
// Validations préliminaires : items valides + provider actif/approved
for (const entry of cart.items) {
const it = itemById.get(entry.itemId);
if (!it) {
return NextResponse.json(
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
{ status: 409 },
);
}
if (!it.provider.active || !it.provider.approved) {
return NextResponse.json(
{ error: `Prestataire ${it.provider.name} indisponible.` },
{ status: 409 },
);
}
if (entry.qty < 1 || entry.qty > it.totalQty) {
return NextResponse.json(
{ error: `Quantité invalide pour « ${it.name} ».` },
{ status: 400 },
);
}
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
return NextResponse.json(
{ error: `Dates invalides pour « ${it.name} ».` },
{ status: 400 },
);
}
}
// Groupe par provider
type Group = {
providerId: string;
providerName: string;
commissionPct: number;
lines: LineInput[];
itemsTotal: Prisma.Decimal;
depositTotal: Prisma.Decimal;
startDate: Date;
endDate: Date;
};
const groups = new Map<string, Group>();
for (const entry of cart.items) {
const it = itemById.get(entry.itemId)!;
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
let g = groups.get(it.provider.id);
if (!g) {
g = {
providerId: it.provider.id,
providerName: it.provider.name,
commissionPct: Number(it.provider.commissionPct),
lines: [],
itemsTotal: new Prisma.Decimal(0),
depositTotal: new Prisma.Decimal(0),
startDate: start,
endDate: end,
};
groups.set(it.provider.id, g);
}
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
g.itemsTotal = g.itemsTotal.add(lineSub);
g.depositTotal = g.depositTotal.add(lineDeposit);
if (start < g.startDate) g.startDate = start;
if (end > g.endDate) g.endDate = end;
}
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
let grandTotal = new Prisma.Decimal(0);
let grandDeposit = new Prisma.Decimal(0);
let rentalBookingIds: string[] = [];
try {
rentalBookingIds = await prisma.$transaction(async (tx) => {
const created: string[] = [];
for (const g of groups.values()) {
// Recheck stock disponible pour chaque ligne
for (const line of g.lines) {
const blocked = await tx.rentalItemAvailability.aggregate({
where: {
itemId: line.itemId,
startDate: { lt: line.endDate },
endDate: { gt: line.startDate },
},
_sum: { qty: true },
});
const item = itemById.get(line.itemId)!;
const used = Number(blocked._sum.qty ?? 0);
const free = item.totalQty - used;
if (line.qty > free) {
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
}
}
const commissionAmount = g.itemsTotal
.mul(g.commissionPct)
.div(100)
.toDecimalPlaces(2);
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
const rb = await tx.rentalBooking.create({
data: {
tenantId: session.user!.id!,
providerId: g.providerId,
startDate: g.startDate,
endDate: g.endDate,
status: RentalBookingStatus.PENDING,
paymentStatus: PaymentStatus.PENDING,
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
depositTotal: g.depositTotal.toDecimalPlaces(2),
commissionAmount,
amount,
currency: "EUR",
lines: {
create: g.lines.map((line) => {
const item = itemById.get(line.itemId)!;
const lineTotal = new Prisma.Decimal(item.pricePerDay)
.mul(line.qty)
.mul(line.nights)
.toDecimalPlaces(2);
return {
itemId: line.itemId,
qty: line.qty,
pricePerDay: new Prisma.Decimal(item.pricePerDay),
deposit: new Prisma.Decimal(item.deposit),
lineTotal,
};
}),
},
},
select: { id: true },
});
// Bloque les dispos
for (const line of g.lines) {
await tx.rentalItemAvailability.create({
data: {
itemId: line.itemId,
startDate: line.startDate,
endDate: line.endDate,
qty: line.qty,
reason: "RENTAL_BOOKING",
rentalBookingId: rb.id,
},
});
}
created.push(rb.id);
grandTotal = grandTotal.add(g.itemsTotal);
grandDeposit = grandDeposit.add(g.depositTotal);
}
return created;
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
{ status: 409 },
);
}
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
await recordAudit({
scope: "rental",
event: "rental.checkout.created",
target: rentalBookingIds.join(","),
actorEmail: session.user.email,
details: {
rentalBookingIds,
amount: totalAmount.toNumber(),
depositTotal: grandDeposit.toNumber(),
providers: Array.from(groups.keys()),
},
});
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,
sameSite: "lax",
path: "/",
maxAge: 0,
});
// Stripe ou paiement différé
if (!isStripeConfigured()) {
return NextResponse.json(
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
{ status: 201 },
);
}
const appUrl = process.env.APP_URL;
if (!appUrl) {
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
}
// Une session Stripe avec une ligne par RentalBooking (agrégée)
const stripe = getStripeClient();
const bookingDetails = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
const line_items = bookingDetails.map((rb) => ({
quantity: 1,
price_data: {
currency: "eur",
unit_amount: toStripeAmountCents(Number(rb.amount)),
product_data: {
name: `Matériel — ${rb.provider.name}`,
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
},
},
}));
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
cancel_url: `${appUrl}/panier?payment=cancel`,
customer_email: session.user.email,
line_items,
metadata: {
type: "rental-bundle",
rentalBookingIds: rentalBookingIds.join(","),
},
});
await prisma.rentalBooking.updateMany({
where: { id: { in: rentalBookingIds } },
data: { stripeSessionId: checkoutSession.id },
});
return NextResponse.json(
{
rentalBookingIds,
totalAmount: totalAmount.toNumber(),
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -4,6 +4,7 @@ import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
@ -51,6 +52,21 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
if (type === "rental-bundle") {
const idsRaw = session.metadata?.rentalBookingIds;
if (!idsRaw) return;
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return;
await prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: RentalBookingStatus.CONFIRMED,
},
});
return;
}
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
@ -79,6 +95,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
if (rentalIdsRaw) {
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
// Marque les paiements échoués + libère les blocages de dispo
await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.FAILED,
status: RentalBookingStatus.CANCELLED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
}
}
if (!bookingId) {
return;
}

View file

@ -0,0 +1,103 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
type Props = {
river: string;
capacity: number;
};
const EMOJI: Record<string, string> = {
SLEEP: "💤",
NAVIGATION: "🛶",
FISHING: "🎣",
COOKING: "🍳",
SAFETY: "🦺",
};
export async function CompleteYourStay({ river, capacity }: Props) {
const providers = await prisma.rentalProvider.findMany({
where: {
active: true,
approved: true,
OR: [
{ isSystemD: true },
{ rivers: { has: river } },
],
},
select: {
id: true,
items: {
where: { active: true },
orderBy: [{ category: "asc" }, { pricePerDay: "asc" }],
take: 24,
select: {
id: true,
name: true,
category: true,
imageUrl: true,
pricePerDay: true,
provider: { select: { name: true, isSystemD: true } },
},
},
},
});
const items = providers.flatMap((p) => p.items).slice(0, 9);
if (items.length === 0) return null;
return (
<section className="my-8 rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
<header className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-emerald-900">
Compléter votre séjour
</h2>
<p className="text-xs text-emerald-800">
Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river},
pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des
prestataires locaux.
</p>
</div>
<Link href="/materiel" className="text-xs font-semibold text-emerald-800 hover:underline">
Voir tout
</Link>
</header>
<ul className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{items.map((it) => (
<li
key={it.id}
className="overflow-hidden rounded-md border border-emerald-100 bg-white shadow-sm"
>
<Link href={`/materiel/${it.id}`} className="block">
<div className="flex aspect-video items-center justify-center bg-emerald-50 text-3xl">
{it.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.imageUrl} alt={it.name} className="h-full w-full object-cover" />
) : (
<span>{EMOJI[it.category] ?? "🎒"}</span>
)}
</div>
<div className="px-2.5 py-1.5">
<p className="truncate text-xs font-semibold text-zinc-900">{it.name}</p>
<div className="flex items-center justify-between text-[10px] text-zinc-500">
<span>{RENTAL_CATEGORY_LABEL[it.category]}</span>
<span className="font-mono font-semibold text-emerald-700">
{Number(it.pricePerDay).toFixed(0)} /j
</span>
</div>
{it.provider.isSystemD ? (
<span className="mt-1 inline-block rounded-full bg-emerald-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
</Link>
</li>
))}
</ul>
</section>
);
}

View file

@ -15,6 +15,7 @@ import { formatAverageRating } from "@/lib/reviews";
import { isStripeConfigured } from "@/lib/stripe";
import { BookingForm } from "../_components/booking-form";
import { CompleteYourStay } from "./_components/CompleteYourStay";
import { CarbetGallery } from "../_components/carbet-gallery";
import { CarbetMap } from "../_components/carbet-map";
import { ReviewsSection } from "../_components/reviews-section";
@ -277,6 +278,8 @@ export default async function PublicCarbetPage({ params }: PageProps) {
</aside>
</div>
<CompleteYourStay river={carbet.river} capacity={carbet.capacity} />
<ReviewsSection
stats={carbet.reviewStats}
reviews={carbet.reviews}

View file

@ -5,6 +5,8 @@ import { PluginProvider } from "@/lib/plugins/client";
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
import { SeasonBanner } from "@/components/SeasonBanner";
import { SiteHeaderGuard } from "@/components/SiteHeaderGuard";
import { RentalCartProvider } from "@/components/RentalCartProvider";
import { readCartFromCookies } from "@/lib/rental-cart-server";
import { LocaleProvider } from "@/lib/i18n/client";
import { dict, getLocale } from "@/lib/i18n/server";
@ -112,6 +114,7 @@ export default async function RootLayout({
const locale = await getLocale();
const messages = await dict(locale);
const initialCart = await readCartFromCookies();
return (
<html
@ -124,9 +127,11 @@ export default async function RootLayout({
>
<PluginProvider enabledKeys={enabledKeys}>
<LocaleProvider locale={locale} messages={messages}>
<SeasonBanner />
<SiteHeaderGuard />
{children}
<RentalCartProvider initial={initialCart}>
<SeasonBanner />
<SiteHeaderGuard />
{children}
</RentalCartProvider>
</LocaleProvider>
</PluginProvider>
</body>

View file

@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useCart } from "@/components/RentalCartProvider";
import { diffDays } from "@/lib/rental-cart";
type Props = {
itemId: string;
pricePerDay: number;
deposit: number;
maxQty: number;
};
function todayPlus(n: number): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
}
export function AddToCart({ itemId, pricePerDay, deposit, maxQty }: Props) {
const { addEntry, cart } = useCart();
const [start, setStart] = useState(todayPlus(7));
const [end, setEnd] = useState(todayPlus(9));
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const nights = Math.max(1, diffDays(start, end));
const subtotal = nights * qty * pricePerDay;
const depositTotal = qty * deposit;
const alreadyInCart = cart.items.some(
(e) => e.itemId === itemId && e.startDate === start && e.endDate === end,
);
function onAdd() {
addEntry({ itemId, qty, startDate: start, endDate: end });
setAdded(true);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 text-sm">
<label className="block">
<span className="text-xs text-zinc-500">Du</span>
<input
type="date"
value={start}
min={todayPlus(0)}
onChange={(e) => setStart(e.target.value)}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
<label className="block">
<span className="text-xs text-zinc-500">Au</span>
<input
type="date"
value={end}
min={start || todayPlus(1)}
onChange={(e) => setEnd(e.target.value)}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
</div>
<label className="block text-sm">
<span className="text-xs text-zinc-500">Quantité</span>
<input
type="number"
value={qty}
min={1}
max={maxQty}
onChange={(e) => setQty(Math.max(1, Math.min(maxQty, Number(e.target.value) || 1)))}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
/>
</label>
<div className="border-t border-zinc-100 pt-2 text-sm text-zinc-700">
<div className="flex justify-between">
<span>
{pricePerDay.toFixed(0)} × {nights} jour{nights > 1 ? "s" : ""} × {qty}
</span>
<span className="font-mono">{subtotal.toFixed(2)} </span>
</div>
{depositTotal > 0 ? (
<div className="flex justify-between text-xs text-zinc-500">
<span>+ Caution (récupérable)</span>
<span className="font-mono">{depositTotal.toFixed(2)} </span>
</div>
) : null}
</div>
{!added ? (
<button
type="button"
onClick={onAdd}
disabled={alreadyInCart || nights === 0}
className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{alreadyInCart ? "Déjà dans le panier" : "Ajouter au panier"}
</button>
) : (
<div className="space-y-2">
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Ajouté au panier
</div>
<Link
href="/panier"
className="block w-full rounded-md bg-emerald-600 px-4 py-2.5 text-center text-sm font-semibold text-white hover:bg-emerald-700"
>
Voir mon panier
</Link>
</div>
)}
</div>
);
}

View file

@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
import { getPublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { AddToCart } from "./_components/AddToCart";
import { AvailabilityPreview } from "./_components/AvailabilityPreview";
export const dynamic = "force-dynamic";
@ -127,10 +128,12 @@ export default async function RentalItemDetailPage({ params }: PageProps) {
) : null}
</div>
<div className="rounded-md bg-emerald-50 px-3 py-2 text-xs text-emerald-900">
🛒 La fonction « Ajouter au panier » arrive avec le Sprint D.
Pour réserver maintenant, contactez directement le prestataire.
</div>
<AddToCart
itemId={item.id}
pricePerDay={Number(item.pricePerDay)}
deposit={Number(item.deposit)}
maxQty={item.totalQty}
/>
<div className="border-t border-zinc-100 pt-3">
<h3 className="text-sm font-semibold text-zinc-900">{item.provider.name}</h3>

View file

@ -0,0 +1,141 @@
import Link from "next/link";
import { requireAuth } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mes locations matériel" };
const STATUS_LABEL: Record<string, string> = {
PENDING: "En attente",
CONFIRMED: "Confirmée",
HANDED_OVER: "Remis",
RETURNED: "Retourné",
CANCELLED: "Annulée",
};
const PAYMENT_LABEL: Record<string, string> = {
PENDING: "Paiement en attente",
AUTHORIZED: "Paiement autorisé",
SUCCEEDED: "Paiement reçu",
FAILED: "Paiement échoué",
REFUNDED: "Remboursé",
};
type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await requireAuth();
const sp = await searchParams;
const rentals = await prisma.rentalBooking.findMany({
where: { tenantId: session.user.id },
orderBy: [{ startDate: "desc" }],
include: {
provider: { select: { id: true, name: true, isSystemD: true, contactPhone: true, contactEmail: true } },
lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
booking: { select: { id: true, carbet: { select: { slug: true, title: true } } } },
},
});
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const showSuccess = sp.payment === "success" || sp.ok;
return (
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-10">
<header>
<h1 className="text-3xl font-semibold text-zinc-900">Mes locations matériel</h1>
<p className="mt-2 text-sm text-zinc-600">
Récap des hamacs, kayaks, pirogues et autres équipements loués pour vos séjours.
</p>
</header>
{showSuccess ? (
<div className="mt-4 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
Votre commande de matériel a bien é enregistrée. Vous recevrez un email de confirmation.
</div>
) : null}
{rentals.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&apos;avez pas encore loué de matériel.{" "}
<Link href="/materiel" className="text-emerald-700 hover:underline">
Découvrir le matériel disponible
</Link>
.
</p>
) : (
<ul className="mt-8 space-y-5">
{rentals.map((rb) => (
<li key={rb.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-zinc-900">{rb.provider.name}</h2>
{rb.booking?.carbet ? (
<p className="text-xs text-zinc-500">
Pour le séjour{" "}
<Link href={`/carbets/${rb.booking.carbet.slug}`} className="hover:underline">
{rb.booking.carbet.title}
</Link>
</p>
) : (
<p className="text-xs text-zinc-500">Location indépendante</p>
)}
</div>
<div className="flex flex-col items-end gap-1 text-xs">
<span className="rounded-full bg-sky-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-sky-800 ring-1 ring-inset ring-sky-300">
{STATUS_LABEL[rb.status] ?? rb.status}
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{PAYMENT_LABEL[rb.paymentStatus] ?? rb.paymentStatus}
</span>
</div>
</div>
<p className="mt-2 text-sm text-zinc-700">
Du {dateFmt.format(rb.startDate)} au {dateFmt.format(rb.endDate)}
</p>
<ul className="mt-3 divide-y divide-zinc-100 text-sm">
{rb.lines.map((line) => (
<li key={line.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium text-zinc-900">{line.qty}×</span>{" "}
<Link href={`/materiel/${line.item.id}`} className="hover:underline">
{line.item.name}
</Link>
<span className="ml-2 text-xs text-zinc-500">
{RENTAL_CATEGORY_LABEL[line.item.category]}
</span>
</span>
<span className="font-mono text-xs text-zinc-700">
{Number(line.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex items-baseline justify-between border-t border-zinc-100 pt-2 text-sm">
<span className="text-zinc-600">Total</span>
<span className="font-mono font-semibold text-zinc-900">
{Number(rb.amount).toFixed(2)} {rb.currency}
</span>
</div>
{(rb.provider.contactPhone || rb.provider.contactEmail) && rb.status !== "CANCELLED" ? (
<p className="mt-2 text-xs text-zinc-500">
Contact prestataire :{" "}
{rb.provider.contactPhone ? <span>📞 {rb.provider.contactPhone} </span> : null}
{rb.provider.contactEmail ? <span> {rb.provider.contactEmail}</span> : null}
</p>
) : null}
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -0,0 +1,249 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCart } from "@/components/RentalCartProvider";
import { diffDays } from "@/lib/rental-cart";
type ItemSnapshot = {
id: string;
name: string;
category: string;
imageUrl: string | null;
pricePerDay: string;
deposit: string;
totalQty: number;
provider: { id: string; name: string; isSystemD: boolean };
};
type Line = {
idx: number;
entry: { itemId: string; qty: number; startDate: string; endDate: string };
item: ItemSnapshot;
};
export function CartReview({ lines }: { lines: Line[] }) {
const router = useRouter();
const { removeEntry, updateEntry, clear } = useCart();
const [busy, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// Groupe par prestataire
const groups = useMemo(() => {
const map = new Map<string, { providerName: string; isSystemD: boolean; lines: Line[]; subtotal: number; deposit: number }>();
for (const l of lines) {
const nights = Math.max(1, diffDays(l.entry.startDate, l.entry.endDate));
const lineSub = nights * l.entry.qty * Number(l.item.pricePerDay);
const lineDeposit = l.entry.qty * Number(l.item.deposit);
const existing = map.get(l.item.provider.id);
if (existing) {
existing.lines.push(l);
existing.subtotal += lineSub;
existing.deposit += lineDeposit;
} else {
map.set(l.item.provider.id, {
providerName: l.item.provider.name,
isSystemD: l.item.provider.isSystemD,
lines: [l],
subtotal: lineSub,
deposit: lineDeposit,
});
}
}
return Array.from(map.values());
}, [lines]);
const grandTotal = groups.reduce((acc, g) => acc + g.subtotal, 0);
const grandDeposit = groups.reduce((acc, g) => acc + g.deposit, 0);
function checkout() {
setError(null);
startTransition(async () => {
const res = await fetch("/api/rentals/checkout", { method: "POST" });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setError(json?.error || `Erreur ${res.status}`);
return;
}
if (json.checkoutUrl) {
window.location.assign(json.checkoutUrl);
return;
}
if (json.rentalBookingIds?.length) {
clear();
router.push(`/mes-locations?ok=${json.rentalBookingIds[0]}`);
return;
}
router.push("/mes-locations");
});
}
return (
<div className="space-y-6">
{groups.map((g) => (
<section key={g.providerName} className="rounded-lg border border-zinc-200 bg-white shadow-sm">
<header className="border-b border-zinc-100 px-4 py-3">
<h2 className="text-base font-semibold text-zinc-900">
{g.providerName}
{g.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</h2>
</header>
<ul className="divide-y divide-zinc-100">
{g.lines.map((l) => (
<CartLineItem
key={l.idx}
line={l}
onRemove={() => removeEntry(l.idx)}
onChangeQty={(qty) => updateEntry(l.idx, { qty })}
onChangeDates={(startDate, endDate) => updateEntry(l.idx, { startDate, endDate })}
disabled={busy}
/>
))}
</ul>
<footer className="flex items-center justify-between border-t border-zinc-100 bg-zinc-50 px-4 py-2 text-sm">
<span className="text-zinc-600">Sous-total prestataire</span>
<span className="font-mono font-semibold text-zinc-900">{g.subtotal.toFixed(2)} </span>
</footer>
</section>
))}
<aside className="sticky bottom-3 z-10 rounded-lg border border-zinc-200 bg-white p-4 shadow-md">
<dl className="space-y-1 text-sm">
<div className="flex justify-between">
<dt>Total location</dt>
<dd className="font-mono font-semibold">{grandTotal.toFixed(2)} </dd>
</div>
{grandDeposit > 0 ? (
<div className="flex justify-between text-xs text-zinc-500">
<dt>+ Caution récupérable</dt>
<dd className="font-mono">{grandDeposit.toFixed(2)} </dd>
</div>
) : null}
<div className="flex justify-between border-t border-zinc-100 pt-2 text-base font-semibold text-zinc-900">
<dt>À régler</dt>
<dd className="font-mono">{(grandTotal + grandDeposit).toFixed(2)} </dd>
</div>
</dl>
{error ? (
<div className="mt-2 rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
{error}
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<button
type="button"
onClick={clear}
disabled={busy}
className="text-xs text-zinc-500 hover:text-zinc-900"
>
Vider le panier
</button>
<button
type="button"
onClick={checkout}
disabled={busy || lines.length === 0}
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{busy ? "Envoi…" : "Valider et payer"}
</button>
</div>
<p className="mt-2 text-center text-[11px] text-zinc-500">
Vous devez être <Link href="/connexion?next=/panier" className="underline">connecté</Link> pour finaliser.
</p>
</aside>
</div>
);
}
function CartLineItem({
line,
onRemove,
onChangeQty,
onChangeDates,
disabled,
}: {
line: Line;
onRemove: () => void;
onChangeQty: (qty: number) => void;
onChangeDates: (start: string, end: string) => void;
disabled?: boolean;
}) {
const nights = Math.max(1, diffDays(line.entry.startDate, line.entry.endDate));
const lineTotal = nights * line.entry.qty * Number(line.item.pricePerDay);
return (
<li className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="hidden h-14 w-20 shrink-0 overflow-hidden rounded bg-zinc-100 sm:block">
{line.item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={line.item.imageUrl} alt={line.item.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-2xl text-zinc-300">
{line.item.category === "SLEEP" ? "💤" :
line.item.category === "NAVIGATION" ? "🛶" :
line.item.category === "FISHING" ? "🎣" :
line.item.category === "COOKING" ? "🍳" : "🦺"}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<Link href={`/materiel/${line.item.id}`} className="font-medium text-zinc-900 hover:underline">
{line.item.name}
</Link>
<div className="mt-1 grid grid-cols-2 gap-1 text-xs sm:grid-cols-4">
<label className="block">
<span className="text-zinc-500">Du</span>
<input
type="date"
value={line.entry.startDate}
onChange={(e) => onChangeDates(e.target.value, line.entry.endDate)}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<label className="block">
<span className="text-zinc-500">Au</span>
<input
type="date"
value={line.entry.endDate}
onChange={(e) => onChangeDates(line.entry.startDate, e.target.value)}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<label className="block">
<span className="text-zinc-500">Qté</span>
<input
type="number"
min={1}
max={line.item.totalQty}
value={line.entry.qty}
onChange={(e) => onChangeQty(Math.max(1, Math.min(line.item.totalQty, Number(e.target.value) || 1)))}
disabled={disabled}
className="block w-full rounded border border-zinc-200 px-1.5 py-0.5"
/>
</label>
<div className="flex flex-col text-right">
<span className="text-zinc-500">{nights} j × {Number(line.item.pricePerDay).toFixed(0)} </span>
<span className="font-mono font-semibold text-zinc-900">{lineTotal.toFixed(2)} </span>
</div>
</div>
</div>
<button
type="button"
onClick={onRemove}
disabled={disabled}
className="text-xs text-rose-700 hover:text-rose-900 disabled:opacity-50"
>
Retirer
</button>
</li>
);
}

81
src/app/panier/page.tsx Normal file
View file

@ -0,0 +1,81 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { readCartFromCookies } from "@/lib/rental-cart-server";
import { CartReview } from "./_components/CartReview";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mon panier matériel" };
export default async function CartPage() {
const cart = await readCartFromCookies();
// Charge les items du panier en bulk pour rendu
const ids = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = ids.length
? await prisma.rentalItem.findMany({
where: { id: { in: ids } },
include: {
provider: { select: { id: true, name: true, isSystemD: true, commissionPct: true } },
},
})
: [];
const itemById = new Map(items.map((i) => [i.id, i]));
const lines = cart.items
.map((entry, idx) => {
const item = itemById.get(entry.itemId);
if (!item) return null;
return {
idx,
entry,
item: {
id: item.id,
name: item.name,
category: item.category,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
deposit: item.deposit.toString(),
totalQty: item.totalQty,
provider: {
id: item.provider.id,
name: item.provider.name,
isSystemD: item.provider.isSystemD,
},
},
};
})
.filter((l): l is NonNullable<typeof l> => l !== null);
return (
<main className="mx-auto max-w-4xl px-6 py-10">
<header className="mb-6">
<Link href="/materiel" className="text-xs text-zinc-500 hover:text-zinc-900">
Continuer mes achats
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">Mon panier matériel</h1>
<p className="mt-1 text-sm text-zinc-600">
{lines.length === 0
? "Votre panier est vide."
: `${lines.length} ligne${lines.length > 1 ? "s" : ""} de location.`}
</p>
</header>
{lines.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center">
<p className="text-sm text-zinc-600">Pas encore d&apos;item dans votre panier.</p>
<Link
href="/materiel"
className="mt-3 inline-block rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Découvrir le matériel
</Link>
</div>
) : (
<CartReview lines={lines} />
)}
</main>
);
}

View file

@ -34,6 +34,16 @@ export default async function ReservationPage({ params }: PageProps) {
include: {
carbet: { select: { title: true, slug: true, river: true } },
tenant: { select: { id: true, email: true } },
rentalBookings: {
select: {
id: true,
status: true,
amount: true,
currency: true,
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { id: true, name: true } } } },
},
},
},
});
if (!booking) notFound();
@ -97,6 +107,34 @@ export default async function ReservationPage({ params }: PageProps) {
</div>
</section>
{booking.rentalBookings.length > 0 ? (
<section className="mt-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold text-zinc-900">Matériel associé</h2>
<ul className="mt-3 space-y-3 text-sm">
{booking.rentalBookings.map((rb) => (
<li key={rb.id} className="rounded-md border border-zinc-100 bg-zinc-50/60 p-3">
<div className="flex justify-between">
<span className="font-medium text-zinc-900">{rb.provider.name}</span>
<span className="font-mono text-xs text-zinc-700">
{Number(rb.amount).toFixed(2)} {rb.currency}
</span>
</div>
<ul className="mt-1 text-xs text-zinc-600">
{rb.lines.map((l, i) => (
<li key={i}>
{l.qty}× <Link href={`/materiel/${l.item.id}`} className="hover:underline">{l.item.name}</Link>
</li>
))}
</ul>
</li>
))}
</ul>
<Link href="/mes-locations" className="mt-3 inline-block text-xs text-emerald-700 hover:underline">
Voir toutes mes locations
</Link>
</section>
) : null}
<div className="mt-6 flex items-center justify-between text-sm">
<Link href={`/carbets/${booking.carbet.slug}`} className="text-zinc-700 hover:text-zinc-900 hover:underline">
Retour au carbet

View file

@ -0,0 +1,22 @@
"use client";
import Link from "next/link";
import { useCart } from "./RentalCartProvider";
export function CartBadge() {
const { totalItems } = useCart();
if (totalItems === 0) return null;
return (
<Link
href="/panier"
className="relative hidden text-zinc-700 hover:text-zinc-900 sm:inline"
aria-label={`Panier (${totalItems} item)`}
>
🛒
<span className="absolute -right-2 -top-2 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-emerald-600 px-1 text-[10px] font-semibold text-white">
{totalItems}
</span>
</Link>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
CART_COOKIE,
EMPTY_CART,
parseCart,
serializeCart,
type Cart,
type CartEntry,
} from "@/lib/rental-cart";
type CartContextValue = {
cart: Cart;
addEntry: (entry: CartEntry) => void;
removeEntry: (index: number) => void;
updateEntry: (index: number, patch: Partial<CartEntry>) => void;
clear: () => void;
totalItems: number;
};
const Ctx = createContext<CartContextValue | null>(null);
function readCookieClient(): Cart {
if (typeof document === "undefined") return EMPTY_CART;
const match = document.cookie.split(/;\s*/).find((c) => c.startsWith(`${CART_COOKIE}=`));
if (!match) return EMPTY_CART;
const value = decodeURIComponent(match.slice(CART_COOKIE.length + 1));
return parseCart(value);
}
function writeCookieClient(cart: Cart): void {
if (typeof document === "undefined") return;
document.cookie = `${CART_COOKIE}=${encodeURIComponent(serializeCart(cart))}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`;
}
export function RentalCartProvider({ children, initial }: { children: ReactNode; initial?: Cart }) {
const [cart, setCart] = useState<Cart>(initial ?? EMPTY_CART);
// Hydrate depuis cookie au mount (au cas où le panier a changé dans un autre onglet)
useEffect(() => {
setCart(readCookieClient());
}, []);
const persist = useCallback((next: Cart) => {
setCart(next);
writeCookieClient(next);
}, []);
const addEntry = useCallback(
(entry: CartEntry) => {
const next = { ...cart, items: [...cart.items, entry] };
persist(next);
},
[cart, persist],
);
const removeEntry = useCallback(
(index: number) => {
const next = { ...cart, items: cart.items.filter((_, i) => i !== index) };
persist(next);
},
[cart, persist],
);
const updateEntry = useCallback(
(index: number, patch: Partial<CartEntry>) => {
const next = {
...cart,
items: cart.items.map((e, i) => (i === index ? { ...e, ...patch } : e)),
};
persist(next);
},
[cart, persist],
);
const clear = useCallback(() => {
persist({ v: 1, items: [] });
}, [persist]);
const value = useMemo<CartContextValue>(
() => ({
cart,
addEntry,
removeEntry,
updateEntry,
clear,
totalItems: cart.items.reduce((acc, e) => acc + e.qty, 0),
}),
[cart, addEntry, removeEntry, updateEntry, clear],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useCart(): CartContextValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useCart must be used inside <RentalCartProvider>");
return ctx;
}

View file

@ -8,6 +8,7 @@ import Link from "next/link";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { CartBadge } from "./CartBadge";
import { SignOutButton } from "./SignOutButton";
export async function SiteHeader() {
@ -40,6 +41,7 @@ export async function SiteHeader() {
</nav>
<div className="flex items-center gap-3 text-sm">
<CartBadge />
{u ? (
<>
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
@ -48,6 +50,9 @@ export async function SiteHeader() {
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes réservations
</Link>
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes locations
</Link>
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mon compte
</Link>

View file

@ -0,0 +1,24 @@
import "server-only";
import { cookies } from "next/headers";
import { CART_COOKIE, EMPTY_CART, parseCart, type Cart } from "./rental-cart";
export async function readCartFromCookies(): Promise<Cart> {
const c = await cookies();
return parseCart(c.get(CART_COOKIE)?.value);
}
export async function writeCartToCookies(cart: Cart): Promise<void> {
const c = await cookies();
c.set(CART_COOKIE, JSON.stringify(cart), {
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 jours
});
}
export async function clearCartCookie(): Promise<void> {
const c = await cookies();
c.set(CART_COOKIE, JSON.stringify(EMPTY_CART), { path: "/", maxAge: 0 });
}

50
src/lib/rental-cart.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* Panier de location de matériel.
*
* Stockage : cookie HTTP `karbe-rental-cart` (JSON encoded).
* Manipulation : client React via context useCart() (composant <CartProvider />).
* Lecture serveur via `readCartFromCookies()`.
*/
import { z } from "zod";
export const CART_COOKIE = "karbe-rental-cart";
export const cartEntrySchema = z.object({
itemId: z.string().min(1).max(200),
qty: z.coerce.number().int().min(1).max(50),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
export const cartSchema = z.object({
v: z.literal(1),
items: z.array(cartEntrySchema).max(50),
updatedAt: z.string().datetime().optional(),
});
export type CartEntry = z.infer<typeof cartEntrySchema>;
export type Cart = z.infer<typeof cartSchema>;
export const EMPTY_CART: Cart = { v: 1, items: [] };
export function parseCart(value: string | undefined | null): Cart {
if (!value) return EMPTY_CART;
try {
const json = JSON.parse(value);
const parsed = cartSchema.safeParse(json);
return parsed.success ? parsed.data : EMPTY_CART;
} catch {
return EMPTY_CART;
}
}
export function serializeCart(cart: Cart): string {
return JSON.stringify({ ...cart, updatedAt: new Date().toISOString() });
}
export function diffDays(start: string, end: string): number {
const s = new Date(start + "T00:00:00Z").getTime();
const e = new Date(end + "T00:00:00Z").getTime();
return Math.max(0, Math.round((e - s) / 86_400_000));
}