feat(rental): Sprint D — panier + checkout + intégration carbet
Some checks failed
CI / test (pull_request) Failing after 1m8s
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:
parent
1165f32a63
commit
91b4d918ea
16 changed files with 1309 additions and 7 deletions
249
src/app/panier/_components/CartReview.tsx
Normal file
249
src/app/panier/_components/CartReview.tsx
Normal 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
81
src/app/panier/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue