From f31fb8a32cc41da6d06d94da0ac4f01280217d53 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 07:49:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(rental):=20Sprint=20B=20=E2=80=94=20catalo?= =?UTF-8?q?gue=20public=20/materiel=20+=20d=C3=A9tail=20item=20+=20dispo?= =?UTF-8?q?=20+=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rentals/items/[id]/availability/route.ts | 31 +++ .../_components/AvailabilityPreview.tsx | 56 ++++++ src/app/materiel/[itemId]/page.tsx | 159 +++++++++++++++ .../materiel/_components/rental-filters.tsx | 100 ++++++++++ .../materiel/_components/rental-item-card.tsx | 76 ++++++++ src/app/materiel/page.tsx | 121 ++++++++++++ src/components/SiteHeader.tsx | 4 +- src/lib/rentals-public.ts | 181 ++++++++++++++++++ 8 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 src/app/api/rentals/items/[id]/availability/route.ts create mode 100644 src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx create mode 100644 src/app/materiel/[itemId]/page.tsx create mode 100644 src/app/materiel/_components/rental-filters.tsx create mode 100644 src/app/materiel/_components/rental-item-card.tsx create mode 100644 src/app/materiel/page.tsx create mode 100644 src/lib/rentals-public.ts diff --git a/src/app/api/rentals/items/[id]/availability/route.ts b/src/app/api/rentals/items/[id]/availability/route.ts new file mode 100644 index 0000000..dc3b8b2 --- /dev/null +++ b/src/app/api/rentals/items/[id]/availability/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { getItemAvailability } from "@/lib/rentals-public"; +import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + const from = parseIsoDate(req.nextUrl.searchParams.get("from")); + const to = parseIsoDate(req.nextUrl.searchParams.get("to")); + if (!from || !to) { + return NextResponse.json( + { error: "Paramètres from et to (YYYY-MM-DD) requis." }, + { status: 400 }, + ); + } + const start = normalizeUtcDayStart(from); + const end = normalizeUtcDayStart(to); + if (end <= start) { + return NextResponse.json({ error: "to doit être > from." }, { status: 400 }); + } + const calendar = await getItemAvailability(id, start, end); + return NextResponse.json({ + itemId: id, + from: start.toISOString(), + to: end.toISOString(), + calendar, + }); +} diff --git a/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx new file mode 100644 index 0000000..4cdf5d9 --- /dev/null +++ b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Day = { + date: string; + availableQty: number; + bookedQty: number; + totalQty: number; +}; + +export function AvailabilityPreview({ itemId }: { itemId: string }) { + const [calendar, setCalendar] = useState(null); + + useEffect(() => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const to = new Date(today.getTime() + 30 * 86_400_000); + const fromStr = today.toISOString().slice(0, 10); + const toStr = to.toISOString().slice(0, 10); + fetch(`/api/rentals/items/${itemId}/availability?from=${fromStr}&to=${toStr}`) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (j?.calendar) setCalendar(j.calendar); + }) + .catch(() => {}); + }, [itemId]); + + if (!calendar) { + return
; + } + + return ( +
+

+ Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) : +

+
+ {calendar.map((d) => { + const ratio = d.availableQty / Math.max(1, d.totalQty); + const tone = + d.availableQty === 0 ? "bg-zinc-300" : + ratio < 0.3 ? "bg-amber-300" : + "bg-emerald-400"; + return ( +
+ ); + })} +
+
+ ); +} diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx new file mode 100644 index 0000000..b6f6aa1 --- /dev/null +++ b/src/app/materiel/[itemId]/page.tsx @@ -0,0 +1,159 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { getPublicRentalItem } from "@/lib/rentals-public"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +import { AvailabilityPreview } from "./_components/AvailabilityPreview"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ itemId: string }> }; + +export async function generateMetadata({ params }: PageProps): Promise { + const { itemId } = await params; + const item = await getPublicRentalItem(itemId); + if (!item) return { title: "Item introuvable", robots: { index: false } }; + return { + title: `${item.name} — Location matériel`, + description: item.description ?? `Location de ${item.name} via ${item.provider.name}.`, + }; +} + +export default async function RentalItemDetailPage({ params }: PageProps) { + const { itemId } = await params; + const item = await getPublicRentalItem(itemId); + if (!item) notFound(); + + const categoryEmoji = + item.category === "SLEEP" ? "💤" : + item.category === "NAVIGATION" ? "🛶" : + item.category === "FISHING" ? "🎣" : + item.category === "COOKING" ? "🍳" : "🦺"; + + return ( +
+ + ← Tout le matériel + + +
+
+
+

+ {RENTAL_CATEGORY_LABEL[item.category]} +

+

{item.name}

+

+ Loué par {item.provider.name} + {item.provider.isSystemD ? ( + + Fournisseur Karbé + + ) : null} +

+
+ +
+ {item.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ {categoryEmoji} +
+ )} +
+ + {item.description ? ( +
+

Description

+

{item.description}

+
+ ) : null} + +
+

Caractéristiques

+
    +
  • +
    Stock disponible
    +
    {item.totalQty} unités
    +
  • + {Number(item.deposit) > 0 ? ( +
  • +
    Caution
    +
    {Number(item.deposit).toFixed(0)} €
    +
  • + ) : null} + {item.withMotor ? ( +
  • ⚙️ Avec moteur
  • + ) : null} + {item.fuelIncluded ? ( +
  • ⛽ Essence incluse
  • + ) : null} + {item.requiresLicense ? ( +
  • 🪪 Permis bateau requis
  • + ) : null} +
+
+ +
+

Disponibilité

+
+ +
+
+
+ + +
+
+ ); +} diff --git a/src/app/materiel/_components/rental-filters.tsx b/src/app/materiel/_components/rental-filters.tsx new file mode 100644 index 0000000..90dc76e --- /dev/null +++ b/src/app/materiel/_components/rental-filters.tsx @@ -0,0 +1,100 @@ +import Link from "next/link"; + +import { RentalCategory } from "@/generated/prisma/enums"; +import { RENTAL_CATEGORIES, RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +type Props = { + filters: { + q?: string; + category?: RentalCategory; + providerId?: string; + river?: string; + }; + rivers: string[]; + providers: { id: string; name: string; isSystemD: boolean }[]; +}; + +export function RentalFilters({ filters, rivers, providers }: Props) { + return ( +
+
+ + + +
+ +
+ Catégorie +
+ {RENTAL_CATEGORIES.map((c) => { + const checked = filters.category === c; + return ( + + ); + })} +
+
+ +
+ + + Réinit. + +
+
+ ); +} diff --git a/src/app/materiel/_components/rental-item-card.tsx b/src/app/materiel/_components/rental-item-card.tsx new file mode 100644 index 0000000..179750b --- /dev/null +++ b/src/app/materiel/_components/rental-item-card.tsx @@ -0,0 +1,76 @@ +import Link from "next/link"; + +import type { PublicRentalItem } from "@/lib/rentals-public"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +export function RentalItemCard({ item }: { item: PublicRentalItem }) { + return ( + +
+ {item.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ {item.category === "SLEEP" ? "💤" : + item.category === "NAVIGATION" ? "🛶" : + item.category === "FISHING" ? "🎣" : + item.category === "COOKING" ? "🍳" : "🦺"} +
+ )} + + {RENTAL_CATEGORY_LABEL[item.category]} + + {item.provider.isSystemD ? ( + + Karbé + + ) : null} +
+
+

+ {item.name} +

+

{item.provider.name}

+

{item.description ?? ""}

+
+ {item.withMotor ? ( + ⚙️ moteur + ) : null} + {item.requiresLicense ? ( + 🪪 permis + ) : null} + {item.fuelIncluded ? ( + ⛽ essence + ) : null} + {Number(item.deposit) > 0 ? ( + + Caution {Number(item.deposit).toFixed(0)} € + + ) : null} +
+
+ + + {Number(item.pricePerDay).toFixed(0)} € + + / jour + + {item.pricePerWeek ? ( + + {Number(item.pricePerWeek).toFixed(0)} € / semaine + + ) : null} +
+
+ + ); +} diff --git a/src/app/materiel/page.tsx b/src/app/materiel/page.tsx new file mode 100644 index 0000000..f18f2ac --- /dev/null +++ b/src/app/materiel/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from "next"; + +import { RentalCategory } from "@/generated/prisma/enums"; +import { isRentalCategory } from "@/lib/rental-category-labels"; +import { + listPublicProviders, + listPublicRentalItems, + listPublicRivers, +} from "@/lib/rentals-public"; + +import { RentalFilters } from "./_components/rental-filters"; +import { RentalItemCard } from "./_components/rental-item-card"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Louer du matériel", + description: + "Hamac, moustiquaire, pirogue, kayak, barque, gilet, réchaud… Toutes les locations de matériel pour réussir votre séjour en carbet guyanais, fournies par l'association System D et des prestataires locaux validés.", +}; + +type PageProps = { + searchParams: Promise<{ + q?: string; + category?: string; + providerId?: string; + river?: string; + }>; +}; + +export default async function MaterialPage({ searchParams }: PageProps) { + const sp = await searchParams; + const filters = { + q: sp.q?.trim() || undefined, + category: sp.category && isRentalCategory(sp.category) ? (sp.category as RentalCategory) : undefined, + providerId: sp.providerId || undefined, + river: sp.river || undefined, + }; + const [items, providers, rivers] = await Promise.all([ + listPublicRentalItems(filters), + listPublicProviders(), + listPublicRivers(), + ]); + + return ( +
+
+

+ Matériel à louer +

+

+ Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage… + Tout le matériel pour réussir votre séjour, mis à disposition par + l'association System D ou par des prestataires + locaux validés. +

+
+ + + +
+

+ {items.length} item{items.length > 1 ? "s" : ""} disponible + {items.length > 1 ? "s" : ""} +

+ {items.length === 0 ? ( +
+ Aucun item ne correspond à votre recherche. Essayez d'élargir + les filtres. +
+ ) : ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ + {providers.length > 0 ? ( +
+

+ Nos prestataires partenaires +

+

+ {providers.length} prestataire{providers.length > 1 ? "s" : ""} valid + {providers.length > 1 ? "és" : "é"} sur Karbé. +

+
    + {providers.map((p) => ( +
  • +
    +

    {p.name}

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

    + Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item + {p.itemsCount > 1 ? "s" : ""} +

    + {p.description ? ( +

    + {p.description} +

    + ) : null} +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 08ffe77..564c466 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -33,8 +33,8 @@ export async function SiteHeader() { Catalogue - - Comment ça marche + + Matériel diff --git a/src/lib/rentals-public.ts b/src/lib/rentals-public.ts new file mode 100644 index 0000000..af08f4a --- /dev/null +++ b/src/lib/rentals-public.ts @@ -0,0 +1,181 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; +import { RentalCategory } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type PublicRentalFilters = { + q?: string; + category?: RentalCategory; + providerId?: string; + river?: string; +}; + +export type PublicRentalItem = { + id: string; + name: string; + description: string | null; + category: RentalCategory; + imageUrl: string | null; + pricePerDay: string; + pricePerWeek: string | null; + deposit: string; + totalQty: number; + withMotor: boolean; + fuelIncluded: boolean; + requiresLicense: boolean; + provider: { + id: string; + name: string; + isSystemD: boolean; + rivers: string[]; + }; +}; + +export async function listPublicRentalItems( + filters: PublicRentalFilters = {}, +): Promise { + const where: Prisma.RentalItemWhereInput = { + active: true, + provider: { active: true, approved: true }, + }; + if (filters.q) { + where.OR = [ + { name: { contains: filters.q, mode: "insensitive" } }, + { description: { contains: filters.q, mode: "insensitive" } }, + ]; + } + if (filters.category) where.category = filters.category; + if (filters.providerId) where.providerId = filters.providerId; + if (filters.river) { + where.provider = { active: true, approved: true, rivers: { has: filters.river } }; + } + + const rows = await prisma.rentalItem.findMany({ + where, + orderBy: [{ category: "asc" }, { name: "asc" }], + take: 200, + include: { + provider: { select: { id: true, name: true, isSystemD: true, rivers: true } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + category: r.category, + imageUrl: r.imageUrl, + pricePerDay: r.pricePerDay.toString(), + pricePerWeek: r.pricePerWeek?.toString() ?? null, + deposit: r.deposit.toString(), + totalQty: r.totalQty, + withMotor: r.withMotor, + fuelIncluded: r.fuelIncluded, + requiresLicense: r.requiresLicense, + provider: r.provider, + })); +} + +export async function getPublicRentalItem(id: string) { + return prisma.rentalItem.findFirst({ + where: { id, active: true, provider: { active: true, approved: true } }, + include: { + provider: { + select: { + id: true, + name: true, + isSystemD: true, + rivers: true, + description: true, + contactEmail: true, + contactPhone: true, + }, + }, + }, + }); +} + +export type PublicProvider = { + id: string; + name: string; + isSystemD: boolean; + rivers: string[]; + itemsCount: number; + description: string | null; +}; + +export async function listPublicProviders(): Promise { + const rows = await prisma.rentalProvider.findMany({ + where: { active: true, approved: true }, + orderBy: [{ isSystemD: "desc" }, { name: "asc" }], + select: { + id: true, + name: true, + isSystemD: true, + rivers: true, + description: true, + _count: { select: { items: { where: { active: true } } } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + isSystemD: r.isSystemD, + rivers: r.rivers, + description: r.description, + itemsCount: r._count.items, + })); +} + +export async function listPublicRivers(): Promise { + const rows = await prisma.rentalProvider.findMany({ + where: { active: true, approved: true }, + select: { rivers: true }, + }); + const set = new Set(); + for (const r of rows) for (const x of r.rivers) set.add(x); + return Array.from(set).sort(); +} + +/** + * Calcule la disponibilité d'un item sur une plage : pour chaque jour, qty + * réservée (somme des RentalItemAvailability qui couvrent ce jour) vs + * totalQty. Renvoie la qty disponible jour par jour. + */ +export async function getItemAvailability( + itemId: string, + from: Date, + to: Date, +): Promise<{ date: string; availableQty: number; bookedQty: number; totalQty: number }[]> { + const item = await prisma.rentalItem.findUnique({ + where: { id: itemId }, + select: { totalQty: true }, + }); + if (!item) return []; + + const blocks = await prisma.rentalItemAvailability.findMany({ + where: { + itemId, + startDate: { lt: to }, + endDate: { gt: from }, + }, + select: { startDate: true, endDate: true, qty: true }, + }); + + const days: { date: string; availableQty: number; bookedQty: number; totalQty: number }[] = []; + const DAY_MS = 86_400_000; + for (let t = from.getTime(); t < to.getTime(); t += DAY_MS) { + const dayStart = new Date(t); + const dayEnd = new Date(t + DAY_MS); + const booked = blocks + .filter((b) => b.startDate < dayEnd && b.endDate > dayStart) + .reduce((acc, b) => acc + b.qty, 0); + days.push({ + date: dayStart.toISOString().slice(0, 10), + bookedQty: booked, + availableQty: Math.max(0, item.totalQty - booked), + totalQty: item.totalQty, + }); + } + return days; +}