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