feat(rental): Sprint B — catalogue public /materiel + détail item + dispo + nav
All checks were successful
CI / test (pull_request) Successful in 2m28s

This commit is contained in:
Claude Integration 2026-06-02 07:49:43 +00:00
parent 1dd2d65626
commit f31fb8a32c
8 changed files with 726 additions and 2 deletions

View file

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

View file

@ -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<Day[] | null>(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 <div className="h-16 w-full animate-pulse rounded-md bg-zinc-100" />;
}
return (
<div>
<p className="text-xs text-zinc-500 mb-2">
Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) :
</p>
<div className="grid grid-cols-15 gap-0.5 sm:grid-cols-30" style={{ gridTemplateColumns: `repeat(${calendar.length}, minmax(0, 1fr))` }}>
{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 (
<div
key={d.date}
className={`h-4 rounded-sm ${tone}`}
title={`${d.date} : ${d.availableQty}/${d.totalQty} dispo`}
/>
);
})}
</div>
</div>
);
}

View file

@ -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<Metadata> {
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 (
<main className="mx-auto max-w-5xl px-6 py-8">
<Link href="/materiel" className="text-sm text-zinc-600 hover:text-zinc-900">
Tout le matériel
</Link>
<div className="mt-3 grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
<header>
<p className="text-xs uppercase tracking-wider text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]}
</p>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-600">
Loué par <strong>{item.provider.name}</strong>
{item.provider.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Fournisseur Karbé
</span>
) : null}
</p>
</header>
<div className="mt-5 overflow-hidden rounded-lg bg-zinc-100">
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
className="aspect-[4/3] w-full object-cover"
/>
) : (
<div className="flex aspect-[4/3] w-full items-center justify-center text-7xl text-zinc-300">
{categoryEmoji}
</div>
)}
</div>
{item.description ? (
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Description</h2>
<p className="mt-2 whitespace-pre-line text-sm text-zinc-700">{item.description}</p>
</section>
) : null}
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Caractéristiques</h2>
<ul className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">Stock disponible</div>
<div className="font-mono font-semibold text-zinc-900">{item.totalQty} unités</div>
</li>
{Number(item.deposit) > 0 ? (
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">Caution</div>
<div className="font-mono font-semibold text-zinc-900">{Number(item.deposit).toFixed(0)} </div>
</li>
) : null}
{item.withMotor ? (
<li className="rounded-md border border-zinc-200 bg-white px-3 py-2"> Avec moteur</li>
) : null}
{item.fuelIncluded ? (
<li className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-emerald-900"> Essence incluse</li>
) : null}
{item.requiresLicense ? (
<li className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">🪪 Permis bateau requis</li>
) : null}
</ul>
</section>
<section className="mt-6">
<h2 className="text-xl font-semibold text-zinc-900">Disponibilité</h2>
<div className="mt-3 rounded-lg border border-zinc-200 bg-white p-4">
<AvailabilityPreview itemId={item.id} />
</div>
</section>
</div>
<aside className="space-y-4 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<div>
<div className="flex items-baseline justify-between">
<span>
<span className="text-3xl font-semibold text-zinc-900">
{Number(item.pricePerDay).toFixed(0)}
</span>
<span className="ml-1 text-sm text-zinc-500">/ jour</span>
</span>
</div>
{item.pricePerWeek ? (
<p className="mt-1 text-xs text-zinc-500">
Forfait semaine : {Number(item.pricePerWeek).toFixed(0)} ( 7 jours)
</p>
) : 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>
<div className="border-t border-zinc-100 pt-3">
<h3 className="text-sm font-semibold text-zinc-900">{item.provider.name}</h3>
{item.provider.isSystemD ? (
<p className="mt-1 text-xs text-emerald-700">Fournisseur officiel Karbé (0% commission).</p>
) : null}
{item.provider.description ? (
<p className="mt-2 text-xs text-zinc-600">{item.provider.description}</p>
) : null}
<div className="mt-2 space-y-1 text-xs text-zinc-700">
{item.provider.contactEmail ? (
<p>📧 <a href={`mailto:${item.provider.contactEmail}`} className="underline">{item.provider.contactEmail}</a></p>
) : null}
{item.provider.contactPhone ? (
<p>📞 {item.provider.contactPhone}</p>
) : null}
<p className="text-zinc-500">
Fleuves desservis : {item.provider.rivers.join(", ") || "—"}
</p>
</div>
</div>
</aside>
</div>
</main>
);
}

View file

@ -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 (
<form method="get" action="/materiel" className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Recherche</span>
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="nom, description…"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Fleuve</span>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-zinc-700">Prestataire</span>
<select
name="providerId"
defaultValue={filters.providerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous prestataires</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isSystemD ? " (Karbé)" : ""}
</option>
))}
</select>
</label>
</div>
<fieldset>
<legend className="text-xs uppercase tracking-wider text-zinc-500">Catégorie</legend>
<div className="mt-1 flex flex-wrap gap-1.5">
{RENTAL_CATEGORIES.map((c) => {
const checked = filters.category === c;
return (
<label
key={c}
className={
"flex cursor-pointer items-center gap-1 rounded-full border px-3 py-1 text-sm transition " +
(checked
? "border-emerald-600 bg-emerald-50 text-emerald-900"
: "border-zinc-300 bg-white text-zinc-700 hover:border-zinc-400")
}
>
<input
type="radio"
name="category"
value={c}
defaultChecked={checked}
className="sr-only"
/>
{RENTAL_CATEGORY_LABEL[c]}
</label>
);
})}
</div>
</fieldset>
<div className="flex items-center gap-2">
<button type="submit" className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">
Filtrer
</button>
<Link href="/materiel" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
</div>
</form>
);
}

View file

@ -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 (
<Link
href={`/materiel/${item.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm transition hover:border-emerald-300 hover:shadow-md"
>
<div className="relative aspect-[4/3] bg-zinc-100">
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center text-4xl text-zinc-300">
{item.category === "SLEEP" ? "💤" :
item.category === "NAVIGATION" ? "🛶" :
item.category === "FISHING" ? "🎣" :
item.category === "COOKING" ? "🍳" : "🦺"}
</div>
)}
<span className="absolute left-2 top-2 rounded-full bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-zinc-800 ring-1 ring-zinc-200">
{RENTAL_CATEGORY_LABEL[item.category]}
</span>
{item.provider.isSystemD ? (
<span className="absolute right-2 top-2 rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
<div className="flex flex-1 flex-col p-3">
<h3 className="text-base font-semibold text-zinc-900 group-hover:text-emerald-700">
{item.name}
</h3>
<p className="mt-0.5 text-xs text-zinc-500">{item.provider.name}</p>
<p className="mt-1 line-clamp-2 text-xs text-zinc-600">{item.description ?? ""}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px]">
{item.withMotor ? (
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-zinc-700"> moteur</span>
) : null}
{item.requiresLicense ? (
<span className="rounded-full bg-amber-50 px-1.5 py-0.5 text-amber-800">🪪 permis</span>
) : null}
{item.fuelIncluded ? (
<span className="rounded-full bg-emerald-50 px-1.5 py-0.5 text-emerald-800"> essence</span>
) : null}
{Number(item.deposit) > 0 ? (
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-zinc-700">
Caution {Number(item.deposit).toFixed(0)}
</span>
) : null}
</div>
<div className="mt-3 flex items-baseline justify-between border-t border-zinc-100 pt-2">
<span>
<span className="text-lg font-semibold text-zinc-900">
{Number(item.pricePerDay).toFixed(0)}
</span>
<span className="ml-1 text-xs text-zinc-500">/ jour</span>
</span>
{item.pricePerWeek ? (
<span className="text-[10px] text-zinc-500">
{Number(item.pricePerWeek).toFixed(0)} / semaine
</span>
) : null}
</div>
</div>
</Link>
);
}

121
src/app/materiel/page.tsx Normal file
View file

@ -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 (
<main className="mx-auto max-w-7xl px-6 py-10">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-zinc-900 sm:text-4xl">
Matériel à louer
</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-600">
Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage
Tout le matériel pour réussir votre séjour, mis à disposition par
l&apos;<strong>association System D</strong> ou par des prestataires
locaux validés.
</p>
</header>
<RentalFilters filters={filters} rivers={rivers} providers={providers} />
<section className="mt-6" aria-live="polite">
<p className="mb-3 text-sm text-zinc-600">
{items.length} item{items.length > 1 ? "s" : ""} disponible
{items.length > 1 ? "s" : ""}
</p>
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucun item ne correspond à votre recherche. Essayez d&apos;élargir
les filtres.
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<li key={item.id}>
<RentalItemCard item={item} />
</li>
))}
</ul>
)}
</section>
{providers.length > 0 ? (
<section className="mt-12 border-t border-zinc-200 pt-8">
<h2 className="text-xl font-semibold text-zinc-900">
Nos prestataires partenaires
</h2>
<p className="mt-1 text-sm text-zinc-600">
{providers.length} prestataire{providers.length > 1 ? "s" : ""} valid
{providers.length > 1 ? "és" : "é"} sur Karbé.
</p>
<ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{providers.map((p) => (
<li
key={p.id}
className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm"
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-base font-semibold text-zinc-900">{p.name}</h3>
{p.isSystemD ? (
<span className="rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
Karbé
</span>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item
{p.itemsCount > 1 ? "s" : ""}
</p>
{p.description ? (
<p className="mt-2 line-clamp-3 text-xs text-zinc-600">
{p.description}
</p>
) : null}
</li>
))}
</ul>
</section>
) : null}
</main>
);
}

View file

@ -33,8 +33,8 @@ export async function SiteHeader() {
<Link href="/carbets" className="hover:text-zinc-900">
Catalogue
</Link>
<Link href="/comment-ca-marche" className="hover:text-zinc-900">
Comment ça marche
<Link href="/materiel" className="hover:text-zinc-900">
Matériel
</Link>
</nav>

181
src/lib/rentals-public.ts Normal file
View file

@ -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<PublicRentalItem[]> {
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<PublicProvider[]> {
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<string[]> {
const rows = await prisma.rentalProvider.findMany({
where: { active: true, approved: true },
select: { rivers: true },
});
const set = new Set<string>();
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;
}