Merge pull request 'feat(rental): Sprint B — catalogue public' (#75) from feat/rental-sprint-b into main
All checks were successful
CI / test (push) Successful in 2m14s
All checks were successful
CI / test (push) Successful in 2m14s
This commit is contained in:
commit
8d7e9cfdc2
8 changed files with 726 additions and 2 deletions
31
src/app/api/rentals/items/[id]/availability/route.ts
Normal file
31
src/app/api/rentals/items/[id]/availability/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
159
src/app/materiel/[itemId]/page.tsx
Normal file
159
src/app/materiel/[itemId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/app/materiel/_components/rental-filters.tsx
Normal file
100
src/app/materiel/_components/rental-filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/app/materiel/_components/rental-item-card.tsx
Normal file
76
src/app/materiel/_components/rental-item-card.tsx
Normal 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
121
src/app/materiel/page.tsx
Normal 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'<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'é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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
181
src/lib/rentals-public.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue