feat(p0): prix/nuit + booking form public + /inscription + /reservations/[id]
This commit is contained in:
parent
f09a680059
commit
e79b6dd141
15 changed files with 590 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Séjour */}
|
||||
{/* Séjour & tarif */}
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour</h2>
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Séjour & tarif</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<FormField label="Capacité" required hint="Voyageurs max">
|
||||
<input
|
||||
|
|
@ -203,6 +204,17 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe
|
|||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Prix / nuit (€)" required hint="Pour le carbet entier.">
|
||||
<input
|
||||
name="nightlyPrice"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
defaultValue={initial.nightlyPrice?.toString() ?? ""}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Capacité min recommandée" hint="Facultatif">
|
||||
<input
|
||||
name="minCapacity"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const baseCarbetSchema = z.object({
|
|||
latitude: z.coerce.number().min(-90).max(90),
|
||||
longitude: z.coerce.number().min(-180).max(180),
|
||||
capacity: z.coerce.number().int().min(1).max(100),
|
||||
nightlyPrice: z.coerce.number().min(0).max(100000),
|
||||
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
|
||||
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
||||
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
|||
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Accès</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">€/nuit</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Médias</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Résas</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Propriétaire</th>
|
||||
|
|
@ -109,7 +110,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
|||
<tbody className="divide-y divide-zinc-100">
|
||||
{carbets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun carbet ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -129,6 +130,7 @@ export default async function CarbetsAdminPage({ searchParams }: PageProps) {
|
|||
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(c.nightlyPrice).toFixed(0)}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.mediaCount}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.bookingsCount}</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
|
||||
|
|
|
|||
64
src/app/api/signup/route.ts
Normal file
64
src/app/api/signup/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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) {
|
|||
</dl>
|
||||
</div>
|
||||
|
||||
<p className="rounded-md bg-emerald-50 px-3 py-2 text-xs text-emerald-800">
|
||||
La réservation en ligne arrive bientôt. En attendant, contactez
|
||||
l'équipe Karbé pour organiser votre séjour.
|
||||
</p>
|
||||
<BookingForm
|
||||
carbetId={carbet.id}
|
||||
slug={carbet.slug}
|
||||
nightlyPrice={Number(carbet.nightlyPrice)}
|
||||
capacity={carbet.capacity}
|
||||
minStayNights={carbet.minStayNights}
|
||||
maxStayNights={carbet.maxStayNights}
|
||||
isAuthenticated={Boolean(viewerId)}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
172
src/app/carbets/_components/booking-form.tsx
Normal file
172
src/app/carbets/_components/booking-form.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span className="text-2xl font-semibold text-zinc-900">{nightlyPrice.toFixed(0)} €</span>
|
||||
<span className="ml-1 text-sm text-zinc-500">/ nuit</span>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">jusqu'à {capacity} voyageurs</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-500">Arrivée</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
min={todayPlus(0)}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-500">Départ</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate || todayPlus(1)}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-zinc-500">Voyageurs</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={capacity}
|
||||
value={guestCount}
|
||||
onChange={(e) => setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))}
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-1 border-t border-zinc-100 pt-3 text-sm text-zinc-700">
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
{nightlyPrice.toFixed(0)} € × {nights} nuit{nights > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="font-mono">{(nightlyPrice * nights).toFixed(2)} €</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-semibold text-zinc-900">
|
||||
<span>Total</span>
|
||||
<span className="font-mono">{total.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!nightsOk && nights > 0 ? (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
|
||||
Séjour entre {minN} et {maxN} nuits requis.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Envoi…" : isAuthenticated ? "Réserver" : "Se connecter pour réserver"}
|
||||
</button>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<p className="text-center text-xs text-zinc-500">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link href={`/inscription?next=${encodeURIComponent(`/carbets/${slug}`)}`} className="text-zinc-900 underline">
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-center text-[11px] text-zinc-500">
|
||||
Le créneau est bloqué dès l'envoi. Statut « En attente » jusqu'à confirmation du paiement.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
</button>
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link
|
||||
href={`/inscription${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`}
|
||||
className="text-zinc-900 underline"
|
||||
>
|
||||
Créer un compte
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
149
src/app/inscription/_components/SignupForm.tsx
Normal file
149
src/app/inscription/_components/SignupForm.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<form action={onSubmit} className="space-y-3">
|
||||
<fieldset disabled={pending} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Prénom</span>
|
||||
<input name="firstName" type="text" required maxLength={100} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nom</span>
|
||||
<input name="lastName" type="text" required maxLength={100} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Email</span>
|
||||
<input name="email" type="email" required maxLength={200} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Mot de passe (8 caractères min.)</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={200}
|
||||
className={inputCls + " mt-0.5"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Téléphone (optionnel)</span>
|
||||
<input name="phone" type="tel" maxLength={40} className={inputCls + " mt-0.5"} />
|
||||
</label>
|
||||
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="text-xs text-zinc-600">Type de compte</legend>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
|
||||
(role === "TOURIST"
|
||||
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||
: "border-zinc-300 hover:bg-zinc-50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="TOURIST"
|
||||
checked={role === "TOURIST"}
|
||||
onChange={() => setRole("TOURIST")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="font-semibold text-zinc-900">Voyageur</span>
|
||||
<span className="text-[11px] text-zinc-500">Réserver un séjour.</span>
|
||||
</label>
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
|
||||
(role === "OWNER"
|
||||
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||
: "border-zinc-300 hover:bg-zinc-50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="OWNER"
|
||||
checked={role === "OWNER"}
|
||||
onChange={() => setRole("OWNER")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="font-semibold text-zinc-900">Hôte</span>
|
||||
<span className="text-[11px] text-zinc-500">Publier un carbet.</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Création…" : "Créer mon compte"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
40
src/app/inscription/page.tsx
Normal file
40
src/app/inscription/page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
|
||||
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Créer un compte</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SignupForm next={next} />
|
||||
|
||||
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
|
||||
Déjà un compte ?{" "}
|
||||
<Link href={`/connexion${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} className="text-zinc-900 underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
110
src/app/reservations/[id]/page.tsx
Normal file
110
src/app/reservations/[id]/page.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
PENDING: "En attente de confirmation",
|
||||
CONFIRMED: "Confirmée",
|
||||
CANCELLED: "Annulée",
|
||||
COMPLETED: "Terminée",
|
||||
};
|
||||
|
||||
const PAYMENT_LABEL: Record<string, string> = {
|
||||
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 (
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Demande de réservation envoyée</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
Votre demande pour <strong>{booking.carbet.title}</strong> a bien été enregistrée. Vous recevrez
|
||||
un email dès que l'hôte ou l'équipe Karbé l'aura confirmée.
|
||||
</p>
|
||||
|
||||
<section className="mt-6 space-y-3 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-xs uppercase tracking-wider text-zinc-500">Référence</span>
|
||||
<code className="font-mono text-sm text-zinc-900">{booking.id}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-zinc-100 pt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Carbet</div>
|
||||
<Link href={`/carbets/${booking.carbet.slug}`} className="font-semibold text-zinc-900 hover:underline">
|
||||
{booking.carbet.title}
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">{booking.carbet.river}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Voyageurs</div>
|
||||
<div className="font-semibold text-zinc-900">
|
||||
{booking.guestCount} personne{booking.guestCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Arrivée</div>
|
||||
<div className="font-semibold text-zinc-900">{dateFmt.format(booking.startDate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Départ</div>
|
||||
<div className="font-semibold text-zinc-900">{dateFmt.format(booking.endDate)}</div>
|
||||
</div>
|
||||
<div className="col-span-2 border-t border-zinc-100 pt-3">
|
||||
<div className="text-xs text-zinc-500">Total ({nights} nuit{nights > 1 ? "s" : ""})</div>
|
||||
<div className="text-2xl font-semibold text-zinc-900 font-mono">
|
||||
{Number(booking.amount).toFixed(2)} {booking.currency}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-3 text-xs">
|
||||
<span className="rounded-full bg-sky-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-sky-800 ring-1 ring-inset ring-sky-300">
|
||||
{STATUS_LABEL[booking.status] ?? booking.status}
|
||||
</span>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
|
||||
{PAYMENT_LABEL[booking.paymentStatus] ?? booking.paymentStatus}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between text-sm">
|
||||
<Link href={`/carbets/${booking.carbet.slug}`} className="text-zinc-700 hover:text-zinc-900 hover:underline">
|
||||
← Retour au carbet
|
||||
</Link>
|
||||
<Link href="/" className="text-zinc-700 hover:text-zinc-900 hover:underline">
|
||||
Accueil
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue