Créer un compte
- Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet.
+ {invite
+ ? `Vous avez été invité à rejoindre « ${invite.orgName} » comme membre CE.`
+ : "Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet."}
-
+
Déjà un compte ?{" "}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 2a05155..ffcc89e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -5,6 +5,8 @@ import { PluginProvider } from "@/lib/plugins/client";
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
import { SeasonBanner } from "@/components/SeasonBanner";
import { SiteHeaderGuard } from "@/components/SiteHeaderGuard";
+import { RentalCartProvider } from "@/components/RentalCartProvider";
+import { readCartFromCookies } from "@/lib/rental-cart-server";
import { LocaleProvider } from "@/lib/i18n/client";
import { dict, getLocale } from "@/lib/i18n/server";
@@ -52,6 +54,21 @@ export const metadata: Metadata = {
},
description:
"Karbé, la marketplace de location de carbets fluviaux de Guyane.",
+ manifest: "/manifest.webmanifest",
+ applicationName: "Karbé",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "black-translucent",
+ title: "Karbé",
+ },
+ icons: {
+ icon: [
+ { url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
+ { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
+ ],
+ apple: "/icons/apple-touch-icon.png",
+ },
openGraph: {
type: "website",
siteName: "Karbé",
@@ -62,6 +79,13 @@ export const metadata: Metadata = {
},
};
+export const viewport = {
+ themeColor: "#059669",
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover" as const,
+};
+
export default async function RootLayout({
children,
}: Readonly<{
@@ -90,6 +114,7 @@ export default async function RootLayout({
const locale = await getLocale();
const messages = await dict(locale);
+ const initialCart = await readCartFromCookies();
return (
-
-
- {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..490f0ab
--- /dev/null
+++ b/src/app/materiel/[itemId]/_components/AddToCart.tsx
@@ -0,0 +1,120 @@
+"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]/_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]/_components/ItemGallery.tsx b/src/app/materiel/[itemId]/_components/ItemGallery.tsx
new file mode 100644
index 0000000..7d6ba55
--- /dev/null
+++ b/src/app/materiel/[itemId]/_components/ItemGallery.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+
+type Media = { id: string; type: "PHOTO" | "VIDEO"; s3Url: string };
+
+export function ItemGallery({
+ media,
+ fallbackEmoji,
+ alt,
+}: {
+ media: Media[];
+ fallbackEmoji: string;
+ alt: string;
+}) {
+ const [idx, setIdx] = useState(0);
+
+ if (media.length === 0) {
+ return (
+
+ {fallbackEmoji}
+
+ );
+ }
+
+ const current = media[idx];
+
+ return (
+
+
+ {current.type === "VIDEO" ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+ {media.length > 1 ? (
+
+ {media.map((m, i) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx
new file mode 100644
index 0000000..d9a56b5
--- /dev/null
+++ b/src/app/materiel/[itemId]/page.tsx
@@ -0,0 +1,164 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+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";
+import { ItemGallery } from "./_components/ItemGallery";
+
+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) {
+ await requirePluginOr404("gear-rental");
+ 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}
+
+
+
+
+ 0
+ ? item.media
+ : item.imageUrl
+ ? [{ id: "legacy", type: "PHOTO", s3Url: item.imageUrl }]
+ : []
+ }
+ alt={item.name}
+ fallbackEmoji={categoryEmoji}
+ />
+
+
+ {item.description ? (
+
+ Description
+ {item.description}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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.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/layout.tsx b/src/app/materiel/layout.tsx
new file mode 100644
index 0000000..d6cebd2
--- /dev/null
+++ b/src/app/materiel/layout.tsx
@@ -0,0 +1,6 @@
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+
+export default async function MaterielLayout({ children }: { children: React.ReactNode }) {
+ await requirePluginOr404("gear-rental");
+ return <>{children}>;
+}
diff --git a/src/app/materiel/page.tsx b/src/app/materiel/page.tsx
new file mode 100644
index 0000000..31fcd77
--- /dev/null
+++ b/src/app/materiel/page.tsx
@@ -0,0 +1,123 @@
+import type { Metadata } from "next";
+
+import { RentalCategory } from "@/generated/prisma/enums";
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+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) {
+ await requirePluginOr404("gear-rental");
+ 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/app/mes-favoris/page.tsx b/src/app/mes-favoris/page.tsx
new file mode 100644
index 0000000..5887400
--- /dev/null
+++ b/src/app/mes-favoris/page.tsx
@@ -0,0 +1,68 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { listFavoriteCarbets } from "@/lib/reels";
+import { buildSrcSet } from "@/lib/image-variants";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = { title: "Mes favoris" };
+
+export default async function MyFavoritesPage() {
+ const session = await auth();
+ if (!session?.user?.id) redirect("/connexion?next=/mes-favoris");
+
+ const carbets = await listFavoriteCarbets(session.user.id);
+
+ return (
+
+ Mes favoris
+
+ {carbets.length === 0
+ ? "Aucun favori pour l'instant — ajoutez des carbets depuis le mode Au fil de l'eau ou les fiches."
+ : `${carbets.length} carbet${carbets.length > 1 ? "s" : ""} sauvegardé${carbets.length > 1 ? "s" : ""}.`}
+
+
+ {carbets.length === 0 ? (
+
+
+ Découvrir des carbets
+
+
+ ) : (
+
+ {carbets.map((c) => (
+ -
+
+ {c.media[0] ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
{c.title}
+
+ {c.river} · {Number(c.nightlyPrice).toFixed(0)} € / nuit
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/mes-locations/page.tsx b/src/app/mes-locations/page.tsx
new file mode 100644
index 0000000..53f7eb6
--- /dev/null
+++ b/src/app/mes-locations/page.tsx
@@ -0,0 +1,150 @@
+import Link from "next/link";
+
+import { CancelRentalButton } from "@/components/CancelRentalButton";
+import { requireAuth } from "@/lib/authorization";
+import { requirePluginOr404 } from "@/lib/plugins/guard";
+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 }) {
+ await requirePluginOr404("gear-rental");
+ 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 (
+
+
+
+ {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}
+
+ {(rb.status === "PENDING" || rb.status === "CONFIRMED") ? (
+
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 5d0099b..ad5f2bd 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,63 +1,9 @@
-import Link from "next/link";
-import { IfPluginEnabled } from "@/components/IfPluginEnabled";
-import { HeroSection } from "@/components/landing/HeroSection";
-import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
-import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
-import { CESection } from "@/components/landing/CESection";
-import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
-import { LandingFooter } from "@/components/landing/Footer";
+import { redirect } from "next/navigation";
/**
- * Page d'accueil — la majorité du contenu est conditionnée par les plugins :
- * - `landing-hero` → hero plein écran
- * - `landing-sections` → 2 expériences + comment ça marche + CE + témoignages + footer riche
- *
- * Si aucun de ces plugins n'est activé, on retombe sur la home historique
- * minimaliste (fallback). Activable depuis /admin/plugins.
+ * Home redirige vers le mode immersif « Au fil de l'eau » par défaut.
+ * L'ancien hero/landing reste accessible via /accueil.
*/
export default function Home() {
- return (
- <>
-
-
-
- Karbé — carbets fluviaux de Guyane
-
-
- La marketplace pour louer des carbets le long des fleuves de Guyane.
-
-
-
- Découvrir les carbets
-
-
- Espace hôte
-
-
-
-
- }
- >
-