feat(p1): calendrier dispo + emails Resend + amount calculé + best-effort welcome/confirmation/refund
This commit is contained in:
parent
4e14854245
commit
b59b8a0af2
7 changed files with 585 additions and 6 deletions
|
|
@ -6,6 +6,7 @@ import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums
|
|||
import { requireRole } from "@/lib/authorization";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendBookingConfirmed, sendBookingRefunded } from "@/lib/email";
|
||||
|
||||
async function audit(event: string, target: string, actor: string | null, details: Record<string, unknown>) {
|
||||
await recordAudit({ scope: "admin.bookings", event, target, actorEmail: actor, details });
|
||||
|
|
@ -31,11 +32,32 @@ export async function updateBookingStatusAction(id: string, status: string) {
|
|||
return { ok: false as const, error: "Statut invalide" };
|
||||
}
|
||||
const session = await auth();
|
||||
await prisma.booking.update({
|
||||
const before = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
});
|
||||
const updated = await prisma.booking.update({
|
||||
where: { id },
|
||||
data: { status: status as BookingStatus },
|
||||
include: {
|
||||
tenant: { select: { email: true, firstName: true } },
|
||||
carbet: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
await audit("booking.status.update", id, session?.user?.email ?? null, { status });
|
||||
if (
|
||||
before?.status !== BookingStatus.CONFIRMED &&
|
||||
updated.status === BookingStatus.CONFIRMED
|
||||
) {
|
||||
sendBookingConfirmed(
|
||||
updated.tenant.email,
|
||||
updated.tenant.firstName,
|
||||
updated.id,
|
||||
updated.carbet.title,
|
||||
updated.startDate,
|
||||
updated.endDate,
|
||||
).catch(() => {});
|
||||
}
|
||||
revalidatePath("/admin/bookings");
|
||||
revalidatePath(`/admin/bookings/${id}`);
|
||||
return { ok: true as const };
|
||||
|
|
@ -60,14 +82,26 @@ export async function updateBookingPaymentAction(id: string, paymentStatus: stri
|
|||
export async function refundBookingAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.booking.update({
|
||||
const updated = await prisma.booking.update({
|
||||
where: { id },
|
||||
data: {
|
||||
paymentStatus: PaymentStatus.REFUNDED,
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
include: {
|
||||
tenant: { select: { email: true, firstName: true } },
|
||||
carbet: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
await audit("booking.refund", id, session?.user?.email ?? null, {});
|
||||
sendBookingRefunded(
|
||||
updated.tenant.email,
|
||||
updated.tenant.firstName,
|
||||
updated.id,
|
||||
updated.carbet.title,
|
||||
updated.amount.toString(),
|
||||
updated.currency,
|
||||
).catch(() => {});
|
||||
revalidatePath("/admin/bookings");
|
||||
revalidatePath(`/admin/bookings/${id}`);
|
||||
return { ok: true as const };
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
parseIsoDate,
|
||||
} from "@/lib/booking";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
|
@ -78,6 +79,9 @@ export async function POST(request: Request) {
|
|||
ownerId: true,
|
||||
capacity: true,
|
||||
status: true,
|
||||
nightlyPrice: true,
|
||||
title: true,
|
||||
owner: { select: { email: true, firstName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -183,6 +187,12 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
const nights = Math.max(
|
||||
1,
|
||||
Math.round((endDate.getTime() - startDate.getTime()) / 86400000),
|
||||
);
|
||||
const computedAmount = Number(carbet.nightlyPrice) * nights;
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
carbetId: carbet.id,
|
||||
|
|
@ -191,7 +201,7 @@ export async function POST(request: Request) {
|
|||
endDate,
|
||||
guestCount,
|
||||
status: BookingStatus.PENDING,
|
||||
amount: 0,
|
||||
amount: computedAmount.toFixed(2),
|
||||
currency: "EUR",
|
||||
},
|
||||
select: {
|
||||
|
|
@ -207,5 +217,34 @@ export async function POST(request: Request) {
|
|||
},
|
||||
});
|
||||
|
||||
// Best-effort emails (n'échouent pas la réservation si Resend down).
|
||||
const tenant = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (tenant) {
|
||||
sendBookingRequestToTenant(
|
||||
tenant.email,
|
||||
tenant.firstName,
|
||||
booking.id,
|
||||
carbet.title,
|
||||
booking.startDate,
|
||||
booking.endDate,
|
||||
computedAmount.toFixed(2),
|
||||
"EUR",
|
||||
).catch(() => {});
|
||||
}
|
||||
if (carbet.owner?.email && tenant) {
|
||||
sendBookingRequestToOwner(
|
||||
carbet.owner.email,
|
||||
carbet.owner.firstName,
|
||||
booking.id,
|
||||
carbet.title,
|
||||
`${tenant.firstName} ${tenant.lastName}`.trim(),
|
||||
booking.startDate,
|
||||
booking.endDate,
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({ booking }, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { UserRole } from "@/generated/prisma/enums";
|
|||
import { hashPassword } from "@/lib/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendSignupWelcome } from "@/lib/email";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
|
@ -60,5 +61,8 @@ export async function POST(req: Request) {
|
|||
details: { role: user.role },
|
||||
});
|
||||
|
||||
// Best-effort welcome email.
|
||||
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
||||
|
||||
return NextResponse.json({ ok: true, userId: user.id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
|
@ -43,6 +43,26 @@ export function BookingForm({
|
|||
const [guestCount, setGuestCount] = useState(Math.min(2, capacity));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blockedDates, setBlockedDates] = useState<Set<string>>(new Set());
|
||||
|
||||
// Fetch availability sur les 90 prochains jours pour griser/avertir.
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const from = todayPlus(0);
|
||||
const to = todayPlus(90);
|
||||
fetch(`/api/carbets/${carbetId}/availability?from=${from}&to=${to}`, { signal: ctrl.signal })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (!j?.calendar) return;
|
||||
const blocked = new Set<string>();
|
||||
for (const d of j.calendar as { date: string; isAvailable: boolean }[]) {
|
||||
if (!d.isAvailable) blocked.add(d.date);
|
||||
}
|
||||
setBlockedDates(blocked);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => ctrl.abort();
|
||||
}, [carbetId]);
|
||||
|
||||
const nights = useMemo(() => Math.max(0, diffDays(startDate, endDate)), [startDate, endDate]);
|
||||
const total = nights * nightlyPrice;
|
||||
|
|
@ -50,7 +70,28 @@ export function BookingForm({
|
|||
const maxN = maxStayNights ?? 365;
|
||||
const nightsOk = nights >= minN && nights <= maxN;
|
||||
const guestOk = guestCount >= 1 && guestCount <= capacity;
|
||||
const canSubmit = nightsOk && guestOk && !busy;
|
||||
|
||||
// Vérifie qu'aucun jour de la plage sélectionnée n'est bloqué.
|
||||
const conflictDates = useMemo(() => {
|
||||
if (blockedDates.size === 0 || nights === 0) return [];
|
||||
const out: string[] = [];
|
||||
const startMs = new Date(startDate + "T00:00:00Z").getTime();
|
||||
for (let i = 0; i < nights; i++) {
|
||||
const d = new Date(startMs + i * 86400000).toISOString().slice(0, 10);
|
||||
if (blockedDates.has(d)) out.push(d);
|
||||
}
|
||||
return out;
|
||||
}, [blockedDates, startDate, nights]);
|
||||
const hasConflict = conflictDates.length > 0;
|
||||
|
||||
const canSubmit = nightsOk && guestOk && !busy && !hasConflict;
|
||||
|
||||
// Prochaines dates bloquées (max 6) pour affichage informatif.
|
||||
const upcomingBlocked = useMemo(() => {
|
||||
return Array.from(blockedDates)
|
||||
.sort()
|
||||
.slice(0, 6);
|
||||
}, [blockedDates]);
|
||||
|
||||
async function submit() {
|
||||
if (!isAuthenticated) {
|
||||
|
|
@ -142,6 +183,31 @@ export function BookingForm({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{hasConflict ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700">
|
||||
Cette plage chevauche {conflictDates.length} jour{conflictDates.length > 1 ? "s" : ""} déjà
|
||||
pris ou bloqué{conflictDates.length > 1 ? "s" : ""} (
|
||||
{conflictDates.slice(0, 3).join(", ")}
|
||||
{conflictDates.length > 3 ? "…" : ""}). Changez les dates.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{upcomingBlocked.length > 0 && !hasConflict ? (
|
||||
<details className="rounded border border-zinc-100 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-600">
|
||||
<summary className="cursor-pointer">Voir les prochaines dates indisponibles</summary>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{upcomingBlocked.map((d) => (
|
||||
<code key={d} className="rounded bg-white px-1.5 py-0.5 text-[10px] text-zinc-700">
|
||||
{d}
|
||||
</code>
|
||||
))}
|
||||
{blockedDates.size > upcomingBlocked.length ? (
|
||||
<span className="text-[10px] text-zinc-500">+ {blockedDates.size - upcomingBlocked.length} autres</span>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : 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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue