feat(p1): calendrier dispo + emails Resend + amount calculé + best-effort welcome/confirmation/refund

This commit is contained in:
Claude Integration 2026-06-01 02:20:38 +00:00
parent 4e14854245
commit b59b8a0af2
7 changed files with 585 additions and 6 deletions

View file

@ -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 };

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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}