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
313
src/app/api/rentals/checkout/route.ts
Normal file
313
src/app/api/rentals/checkout/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
103
src/app/carbets/[slug]/_components/CompleteYourStay.tsx
Normal file
103
src/app/carbets/[slug]/_components/CompleteYourStay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
119
src/app/materiel/[itemId]/_components/AddToCart.tsx
Normal file
119
src/app/materiel/[itemId]/_components/AddToCart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
141
src/app/mes-locations/page.tsx
Normal file
141
src/app/mes-locations/page.tsx
Normal 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 été 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'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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
22
src/components/CartBadge.tsx
Normal file
22
src/components/CartBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/RentalCartProvider.tsx
Normal file
109
src/components/RentalCartProvider.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
24
src/lib/rental-cart-server.ts
Normal file
24
src/lib/rental-cart-server.ts
Normal 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
50
src/lib/rental-cart.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue