diff --git a/src/app/api/rentals/checkout/route.ts b/src/app/api/rentals/checkout/route.ts new file mode 100644 index 0000000..faaeb8e --- /dev/null +++ b/src/app/api/rentals/checkout/route.ts @@ -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(); + 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 }, + ); +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 1e4296f..a6b9b99 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -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; } diff --git a/src/app/carbets/[slug]/_components/CompleteYourStay.tsx b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx new file mode 100644 index 0000000..3e87eae --- /dev/null +++ b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx @@ -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 = { + 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 ( +
+
+
+

+ Compléter votre séjour +

+

+ Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river}, + pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des + prestataires locaux. +

+
+ + Voir tout → + +
+ +
    + {items.map((it) => ( +
  • + +
    + {it.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {it.name} + ) : ( + {EMOJI[it.category] ?? "🎒"} + )} +
    +
    +

    {it.name}

    +
    + {RENTAL_CATEGORY_LABEL[it.category]} + + {Number(it.pricePerDay).toFixed(0)} €/j + +
    + {it.provider.isSystemD ? ( + + Karbé + + ) : null} +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index ae53374..640d7c6 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -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) { + + - - - {children} + + + + {children} + diff --git a/src/app/materiel/[itemId]/_components/AddToCart.tsx b/src/app/materiel/[itemId]/_components/AddToCart.tsx new file mode 100644 index 0000000..0ee7e36 --- /dev/null +++ b/src/app/materiel/[itemId]/_components/AddToCart.tsx @@ -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 ( +
+
+ + +
+ + + +
+
+ + {pricePerDay.toFixed(0)} € × {nights} jour{nights > 1 ? "s" : ""} × {qty} + + {subtotal.toFixed(2)} € +
+ {depositTotal > 0 ? ( +
+ + Caution (récupérable) + {depositTotal.toFixed(2)} € +
+ ) : null} +
+ + {!added ? ( + + ) : ( +
+
+ ✓ Ajouté au panier +
+ + Voir mon panier + +
+ )} +
+ ); +} diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx index b6f6aa1..00e8348 100644 --- a/src/app/materiel/[itemId]/page.tsx +++ b/src/app/materiel/[itemId]/page.tsx @@ -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} -
- 🛒 La fonction « Ajouter au panier » arrive avec le Sprint D. - Pour réserver maintenant, contactez directement le prestataire. -
+

{item.provider.name}

diff --git a/src/app/mes-locations/page.tsx b/src/app/mes-locations/page.tsx new file mode 100644 index 0000000..606ae92 --- /dev/null +++ b/src/app/mes-locations/page.tsx @@ -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 = { + PENDING: "En attente", + CONFIRMED: "Confirmée", + HANDED_OVER: "Remis", + RETURNED: "Retourné", + CANCELLED: "Annulée", +}; + +const PAYMENT_LABEL: Record = { + 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 ( +
+
+

Mes locations matériel

+

+ Récap des hamacs, kayaks, pirogues et autres équipements loués pour vos séjours. +

+
+ + {showSuccess ? ( +
+ ✓ Votre commande de matériel a bien été enregistrée. Vous recevrez un email de confirmation. +
+ ) : null} + + {rentals.length === 0 ? ( +

+ Vous n'avez pas encore loué de matériel.{" "} + + Découvrir le matériel disponible + + . +

+ ) : ( +
    + {rentals.map((rb) => ( +
  • +
    +
    +

    {rb.provider.name}

    + {rb.booking?.carbet ? ( +

    + Pour le séjour{" "} + + {rb.booking.carbet.title} + +

    + ) : ( +

    Location indépendante

    + )} +
    +
    + + {STATUS_LABEL[rb.status] ?? rb.status} + + + {PAYMENT_LABEL[rb.paymentStatus] ?? rb.paymentStatus} + +
    +
    + +

    + Du {dateFmt.format(rb.startDate)} au {dateFmt.format(rb.endDate)} +

    + +
      + {rb.lines.map((line) => ( +
    • + + {line.qty}×{" "} + + {line.item.name} + + + {RENTAL_CATEGORY_LABEL[line.item.category]} + + + + {Number(line.lineTotal).toFixed(2)} € + +
    • + ))} +
    + +
    + Total + + {Number(rb.amount).toFixed(2)} {rb.currency} + +
    + + {(rb.provider.contactPhone || rb.provider.contactEmail) && rb.status !== "CANCELLED" ? ( +

    + Contact prestataire :{" "} + {rb.provider.contactPhone ? 📞 {rb.provider.contactPhone} : null} + {rb.provider.contactEmail ? ✉ {rb.provider.contactEmail} : null} +

    + ) : null} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/panier/_components/CartReview.tsx b/src/app/panier/_components/CartReview.tsx new file mode 100644 index 0000000..e890656 --- /dev/null +++ b/src/app/panier/_components/CartReview.tsx @@ -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(null); + + // Groupe par prestataire + const groups = useMemo(() => { + const map = new Map(); + 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 ( +
+ {groups.map((g) => ( +
+
+

+ {g.providerName} + {g.isSystemD ? ( + + Karbé + + ) : null} +

+
+
    + {g.lines.map((l) => ( + removeEntry(l.idx)} + onChangeQty={(qty) => updateEntry(l.idx, { qty })} + onChangeDates={(startDate, endDate) => updateEntry(l.idx, { startDate, endDate })} + disabled={busy} + /> + ))} +
+
+ Sous-total prestataire + {g.subtotal.toFixed(2)} € +
+
+ ))} + + +
+ ); +} + +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 ( +
  • +
    + {line.item.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {line.item.name} + ) : ( +
    + {line.item.category === "SLEEP" ? "💤" : + line.item.category === "NAVIGATION" ? "🛶" : + line.item.category === "FISHING" ? "🎣" : + line.item.category === "COOKING" ? "🍳" : "🦺"} +
    + )} +
    +
    + + {line.item.name} + +
    + + + +
    + {nights} j × {Number(line.item.pricePerDay).toFixed(0)} € + {lineTotal.toFixed(2)} € +
    +
    +
    + +
  • + ); +} diff --git a/src/app/panier/page.tsx b/src/app/panier/page.tsx new file mode 100644 index 0000000..a547512 --- /dev/null +++ b/src/app/panier/page.tsx @@ -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 => l !== null); + + return ( +
    +
    + + ← Continuer mes achats + +

    Mon panier matériel

    +

    + {lines.length === 0 + ? "Votre panier est vide." + : `${lines.length} ligne${lines.length > 1 ? "s" : ""} de location.`} +

    +
    + + {lines.length === 0 ? ( +
    +

    Pas encore d'item dans votre panier.

    + + Découvrir le matériel + +
    + ) : ( + + )} +
    + ); +} diff --git a/src/app/reservations/[id]/page.tsx b/src/app/reservations/[id]/page.tsx index 857bf1d..69e1607 100644 --- a/src/app/reservations/[id]/page.tsx +++ b/src/app/reservations/[id]/page.tsx @@ -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) {
    + {booking.rentalBookings.length > 0 ? ( +
    +

    Matériel associé

    +
      + {booking.rentalBookings.map((rb) => ( +
    • +
      + {rb.provider.name} + + {Number(rb.amount).toFixed(2)} {rb.currency} + +
      +
        + {rb.lines.map((l, i) => ( +
      • + {l.qty}× {l.item.name} +
      • + ))} +
      +
    • + ))} +
    + + Voir toutes mes locations → + +
    + ) : null} +
    ← Retour au carbet diff --git a/src/components/CartBadge.tsx b/src/components/CartBadge.tsx new file mode 100644 index 0000000..e904b8d --- /dev/null +++ b/src/components/CartBadge.tsx @@ -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 ( + + 🛒 + + {totalItems} + + + ); +} diff --git a/src/components/RentalCartProvider.tsx b/src/components/RentalCartProvider.tsx new file mode 100644 index 0000000..9f7c7db --- /dev/null +++ b/src/components/RentalCartProvider.tsx @@ -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) => void; + clear: () => void; + totalItems: number; +}; + +const Ctx = createContext(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(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) => { + 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( + () => ({ + cart, + addEntry, + removeEntry, + updateEntry, + clear, + totalItems: cart.items.reduce((acc, e) => acc + e.qty, 0), + }), + [cart, addEntry, removeEntry, updateEntry, clear], + ); + + return {children}; +} + +export function useCart(): CartContextValue { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useCart must be used inside "); + return ctx; +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index f823a9e..b58c423 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -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() {
    + {u ? ( <> @@ -48,6 +50,9 @@ export async function SiteHeader() { Mes réservations + + Mes locations + Mon compte diff --git a/src/lib/rental-cart-server.ts b/src/lib/rental-cart-server.ts new file mode 100644 index 0000000..4d8989b --- /dev/null +++ b/src/lib/rental-cart-server.ts @@ -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 { + const c = await cookies(); + return parseCart(c.get(CART_COOKIE)?.value); +} + +export async function writeCartToCookies(cart: Cart): Promise { + 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 { + const c = await cookies(); + c.set(CART_COOKIE, JSON.stringify(EMPTY_CART), { path: "/", maxAge: 0 }); +} diff --git a/src/lib/rental-cart.ts b/src/lib/rental-cart.ts new file mode 100644 index 0000000..c34b31e --- /dev/null +++ b/src/lib/rental-cart.ts @@ -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 ). + * 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; +export type Cart = z.infer; + +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)); +}