diff --git a/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql b/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql new file mode 100644 index 0000000..e53b6bf --- /dev/null +++ b/prisma/migrations/20260601150000_carbet_nightly_price/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Carbet" ADD COLUMN "nightlyPrice" DECIMAL(10,2) NOT NULL DEFAULT 0; +UPDATE "Carbet" SET "nightlyPrice" = 80 WHERE "nightlyPrice" = 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index caa7314..e9aaf6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,6 +124,8 @@ model Carbet { // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). roadAccessNote String? capacity Int + // Prix par nuit pour le carbet entier (toute capacité). En euros. + nightlyPrice Decimal @db.Decimal(10, 2) @default(0) // Contraintes séjour (plugin min-stay). null = pas de contrainte. minStayNights Int? maxStayNights Int? diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index 5e8f635..02c0d80 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -87,6 +87,7 @@ export default async function EditCarbetPage({ params }: PageProps) { latitude: carbet.latitude.toString(), longitude: carbet.longitude.toString(), capacity: carbet.capacity, + nightlyPrice: carbet.nightlyPrice.toString(), accessType: carbet.accessType, roadAccessNote: carbet.roadAccessNote, pirogueDurationMin: carbet.pirogueDurationMin, diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx index 6a4f7e4..260996b 100644 --- a/src/app/admin/carbets/_components/CarbetForm.tsx +++ b/src/app/admin/carbets/_components/CarbetForm.tsx @@ -18,6 +18,7 @@ export type CarbetFormInitial = { latitude?: number | string; longitude?: number | string; capacity?: number; + nightlyPrice?: number | string; accessType?: string; roadAccessNote?: string | null; pirogueDurationMin?: number | null; @@ -188,9 +189,9 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe - {/* Séjour */} + {/* Séjour & tarif */}
-

Séjour

+

Séjour & tarif

+ + + Fleuve Accès Cap. + €/nuit Médias Résas Propriétaire @@ -109,7 +110,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) { {carbets.length === 0 ? ( - + Aucun carbet ne correspond aux filtres. @@ -129,6 +130,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) { {c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"} {c.capacity} + {Number(c.nightlyPrice).toFixed(0)} {c.mediaCount} {c.bookingsCount} {c.ownerName} diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts new file mode 100644 index 0000000..db2f49d --- /dev/null +++ b/src/app/api/signup/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { UserRole } from "@/generated/prisma/enums"; +import { hashPassword } from "@/lib/password"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; + +export const runtime = "nodejs"; + +const schema = z.object({ + email: z.string().trim().toLowerCase().email().max(200), + password: z.string().min(8).max(200), + firstName: z.string().trim().min(1).max(100), + lastName: z.string().trim().min(1).max(100), + phone: z.string().trim().max(40).optional().nullable(), + role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST), +}); + +export async function POST(req: Request) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); + } + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }, + { status: 400 }, + ); + } + const data = parsed.data; + + const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } }); + if (existing) { + return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 }); + } + + const passwordHash = await hashPassword(data.password); + const user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone?.trim() || null, + role: data.role, + isActive: true, + }, + select: { id: true, email: true, role: true }, + }); + + await recordAudit({ + scope: "public.signup", + event: "user.create", + target: user.id, + actorEmail: user.email, + details: { role: user.role }, + }); + + return NextResponse.json({ ok: true, userId: user.id }); +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index 9a3e51c..e51590a 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -12,6 +12,7 @@ import { import { MediaType, UserRole } from "@/generated/prisma/enums"; import { formatAverageRating } from "@/lib/reviews"; +import { BookingForm } from "../_components/booking-form"; import { CarbetGallery } from "../_components/carbet-gallery"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; @@ -226,10 +227,15 @@ export default async function PublicCarbetPage({ params }: PageProps) {
-

- La réservation en ligne arrive bientôt. En attendant, contactez - l'équipe Karbé pour organiser votre séjour. -

+ diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx new file mode 100644 index 0000000..2c4f1e2 --- /dev/null +++ b/src/app/carbets/_components/booking-form.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +type Props = { + carbetId: string; + slug: string; + nightlyPrice: number; + capacity: number; + minStayNights: number | null; + maxStayNights: number | null; + isAuthenticated: boolean; +}; + +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); +} + +function diffDays(a: string, b: string): number { + if (!a || !b) return 0; + const da = new Date(a + "T00:00:00Z").getTime(); + const db = new Date(b + "T00:00:00Z").getTime(); + return Math.round((db - da) / 86400000); +} + +export function BookingForm({ + carbetId, + slug, + nightlyPrice, + capacity, + minStayNights, + maxStayNights, + isAuthenticated, +}: Props) { + const router = useRouter(); + const [startDate, setStartDate] = useState(todayPlus(7)); + const [endDate, setEndDate] = useState(todayPlus(7 + (minStayNights ?? 2))); + const [guestCount, setGuestCount] = useState(Math.min(2, capacity)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]); + const total = nights * nightlyPrice; + const minN = minStayNights ?? 1; + const maxN = maxStayNights ?? 365; + const nightsOk = nights >= minN && nights <= maxN; + const guestOk = guestCount >= 1 && guestCount <= capacity; + const canSubmit = nightsOk && guestOk && !busy; + + async function submit() { + if (!isAuthenticated) { + const next = `/carbets/${slug}`; + router.push(`/connexion?next=${encodeURIComponent(next)}`); + return; + } + setBusy(true); + setError(null); + try { + const res = await fetch("/api/bookings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carbetId, startDate, endDate, guestCount }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(json?.error || `Erreur ${res.status}`); + } + router.push(`/reservations/${json.id ?? json.booking?.id ?? ""}`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ {nightlyPrice.toFixed(0)} € + / nuit +
+ jusqu'à {capacity} voyageurs +
+ +
+ + +
+ + + +
+
+ + {nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""} + + {(nightlyPrice * nights).toFixed(2)} € +
+
+ Total + {total.toFixed(2)} € +
+
+ + {!nightsOk && nights > 0 ? ( +
+ Séjour entre {minN} et {maxN} nuits requis. +
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + + + {!isAuthenticated ? ( +

+ Pas encore de compte ?{" "} + + Créer un compte + +

+ ) : null} + +

+ Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation du paiement. +

+
+ ); +} diff --git a/src/app/connexion/page.tsx b/src/app/connexion/page.tsx index 032a91b..e66082e 100644 --- a/src/app/connexion/page.tsx +++ b/src/app/connexion/page.tsx @@ -1,11 +1,16 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; import { auth, signIn } from "@/auth"; -export default async function SignInPage() { +type Props = { searchParams: Promise<{ next?: string }> }; + +export default async function SignInPage({ searchParams }: Props) { const session = await auth(); + const sp = await searchParams; + const next = sp.next && sp.next.startsWith("/") ? sp.next : "/"; if (session?.user?.id) { - redirect("/"); + redirect(next); } return ( @@ -48,6 +53,15 @@ export default async function SignInPage() { > Se connecter +

+ Pas encore de compte ?{" "} + + Créer un compte + +

); diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx new file mode 100644 index 0000000..2ffd914 --- /dev/null +++ b/src/app/inscription/_components/SignupForm.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; + +type Props = { next: string }; + +export function SignupForm({ next }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST"); + + function onSubmit(formData: FormData) { + setError(null); + const email = (formData.get("email") as string | null)?.trim() ?? ""; + const password = (formData.get("password") as string | null) ?? ""; + const firstName = (formData.get("firstName") as string | null)?.trim() ?? ""; + const lastName = (formData.get("lastName") as string | null)?.trim() ?? ""; + const phone = (formData.get("phone") as string | null)?.trim() ?? ""; + + if (password.length < 8) { + setError("Le mot de passe doit faire au moins 8 caractères."); + return; + } + + startTransition(async () => { + const res = await fetch("/api/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setError(json?.error || `Erreur ${res.status}`); + return; + } + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + if (result?.error) { + setError("Compte créé mais connexion impossible. Essayez la page de connexion."); + return; + } + router.push(next); + router.refresh(); + }); + } + + const inputCls = + "w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"; + + return ( +
+
+
+ + +
+ + + + + + + +
+ Type de compte +
+ + +
+
+ + {error ? ( +
{error}
+ ) : null} + + +
+
+ ); +} diff --git a/src/app/inscription/page.tsx b/src/app/inscription/page.tsx new file mode 100644 index 0000000..35e871e --- /dev/null +++ b/src/app/inscription/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; + +import { auth } from "@/auth"; +import { SignupForm } from "./_components/SignupForm"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + searchParams: Promise<{ next?: string }>; +}; + +export default async function SignupPage({ searchParams }: PageProps) { + const session = await auth(); + const sp = await searchParams; + const next = sp.next && sp.next.startsWith("/") ? sp.next : "/"; + if (session?.user?.id) redirect(next); + + return ( +
+
+
+

Créer un compte

+

+ Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet. +

+
+ + + +

+ Déjà un compte ?{" "} + + Se connecter + +

+
+
+ ); +} diff --git a/src/app/reservations/[id]/page.tsx b/src/app/reservations/[id]/page.tsx new file mode 100644 index 0000000..857bf1d --- /dev/null +++ b/src/app/reservations/[id]/page.tsx @@ -0,0 +1,110 @@ +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ id: string }> }; + +const STATUS_LABEL: Record = { + PENDING: "En attente de confirmation", + CONFIRMED: "Confirmée", + CANCELLED: "Annulée", + COMPLETED: "Terminée", +}; + +const PAYMENT_LABEL: Record = { + PENDING: "Paiement en attente", + AUTHORIZED: "Paiement autorisé", + SUCCEEDED: "Paiement reçu", + FAILED: "Paiement échoué", + REFUNDED: "Remboursé", +}; + +export default async function ReservationPage({ params }: PageProps) { + const { id } = await params; + const session = await auth(); + if (!session?.user?.id) redirect(`/connexion?next=/reservations/${id}`); + + const booking = await prisma.booking.findUnique({ + where: { id }, + include: { + carbet: { select: { title: true, slug: true, river: true } }, + tenant: { select: { id: true, email: true } }, + }, + }); + if (!booking) notFound(); + + const isOwner = booking.tenant.id === session.user.id; + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isOwner && !isAdmin) notFound(); + + const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }); + const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000)); + + return ( +
+

Demande de réservation envoyée

+

+ Votre demande pour {booking.carbet.title} a bien été enregistrée. Vous recevrez + un email dès que l'hôte ou l'équipe Karbé l'aura confirmée. +

+ +
+
+ Référence + {booking.id} +
+
+
+
Carbet
+ + {booking.carbet.title} + +
{booking.carbet.river}
+
+
+
Voyageurs
+
+ {booking.guestCount} personne{booking.guestCount > 1 ? "s" : ""} +
+
+
+
Arrivée
+
{dateFmt.format(booking.startDate)}
+
+
+
Départ
+
{dateFmt.format(booking.endDate)}
+
+
+
Total ({nights} nuit{nights > 1 ? "s" : ""})
+
+ {Number(booking.amount).toFixed(2)} {booking.currency} +
+
+
+
+ + {STATUS_LABEL[booking.status] ?? booking.status} + + + {PAYMENT_LABEL[booking.paymentStatus] ?? booking.paymentStatus} + +
+
+ +
+ + ← Retour au carbet + + + Accueil + +
+
+ ); +} diff --git a/src/lib/admin/carbets.ts b/src/lib/admin/carbets.ts index b6e1a92..bf773e2 100644 --- a/src/lib/admin/carbets.ts +++ b/src/lib/admin/carbets.ts @@ -13,6 +13,7 @@ export type AdminCarbetListItem = { title: string; river: string; capacity: number; + nightlyPrice: string; status: CarbetStatus; accessType: AccessType; ownerName: string; @@ -52,6 +53,7 @@ export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promis title: true, river: true, capacity: true, + nightlyPrice: true, status: true, accessType: true, updatedAt: true, @@ -66,6 +68,7 @@ export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promis title: r.title, river: r.river, capacity: r.capacity, + nightlyPrice: r.nightlyPrice.toString(), status: r.status, accessType: r.accessType, ownerName: `${r.owner.firstName} ${r.owner.lastName}`.trim() || r.owner.email, diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index 82ed693..61af5c4 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -27,6 +27,7 @@ export type PublicCarbetDetail = { accessType: AccessType; roadAccessNote: string | null; capacity: number; + nightlyPrice: string; minStayNights: number | null; maxStayNights: number | null; minCapacity: number | null; @@ -60,6 +61,7 @@ export const getPublicCarbet = cache( accessType: true, roadAccessNote: true, capacity: true, + nightlyPrice: true, minStayNights: true, maxStayNights: true, minCapacity: true, @@ -110,6 +112,7 @@ export const getPublicCarbet = cache( accessType: carbet.accessType, roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, + nightlyPrice: carbet.nightlyPrice.toString(), minStayNights: carbet.minStayNights, maxStayNights: carbet.maxStayNights, minCapacity: carbet.minCapacity,