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}
|
||||
|
|
|
|||
207
src/lib/email.ts
Normal file
207
src/lib/email.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Service email — Resend si `RESEND_API_KEY` est configuré, sinon log console.
|
||||
*
|
||||
* Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec
|
||||
* d'envoi (best-effort, le booking est l'action principale).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
let resendClient: import("resend").Resend | null | undefined;
|
||||
|
||||
async function getResend(): Promise<import("resend").Resend | null> {
|
||||
if (resendClient !== undefined) return resendClient;
|
||||
const key = process.env.RESEND_API_KEY?.trim();
|
||||
if (!key) {
|
||||
resendClient = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { Resend } = await import("resend");
|
||||
resendClient = new Resend(key);
|
||||
return resendClient;
|
||||
} catch (e) {
|
||||
console.error("[email] resend init failed:", e instanceof Error ? e.message : e);
|
||||
resendClient = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type EmailOpts = {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé <no-reply@karbe.cosmolan.fr>";
|
||||
|
||||
export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> {
|
||||
const client = await getResend();
|
||||
if (!client) {
|
||||
console.log(
|
||||
"[email] dry-run (no RESEND_API_KEY):",
|
||||
JSON.stringify({ to: opts.to, subject: opts.subject }),
|
||||
);
|
||||
return { ok: true, reason: "dry-run" };
|
||||
}
|
||||
try {
|
||||
const { data, error } = await client.emails.send({
|
||||
from: DEFAULT_FROM,
|
||||
to: Array.isArray(opts.to) ? opts.to : [opts.to],
|
||||
subject: opts.subject,
|
||||
html: opts.html,
|
||||
text: opts.text,
|
||||
replyTo: opts.replyTo,
|
||||
});
|
||||
if (error) {
|
||||
console.error("[email] resend error:", error);
|
||||
return { ok: false, reason: error.message };
|
||||
}
|
||||
return { ok: true, id: data?.id };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[email] send failed:", msg);
|
||||
return { ok: false, reason: msg };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Templates ----------
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
||||
|
||||
const baseStyle = `
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #18181b;
|
||||
max-width: 580px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
function wrap(title: string, content: string): string {
|
||||
return `<!doctype html><html><body style="background:#fafafa;margin:0;padding:24px 12px;">
|
||||
<div style="${baseStyle}background:white;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
|
||||
<h1 style="margin:0 0 16px;font-size:22px;font-weight:600;color:#18181b;">${title}</h1>
|
||||
${content}
|
||||
<hr style="margin:24px 0;border:0;border-top:1px solid #e4e4e7;" />
|
||||
<p style="font-size:11px;color:#71717a;margin:0;">
|
||||
Karbé · <a href="${SITE_URL}" style="color:#71717a;">${SITE_URL}</a><br/>
|
||||
Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
|
||||
</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export async function sendSignupWelcome(to: string, firstName: string): Promise<void> {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: "Bienvenue sur Karbé",
|
||||
html: wrap(
|
||||
`Bienvenue ${firstName} !`,
|
||||
`<p>Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.</p>
|
||||
<p><a href="${SITE_URL}/carbets" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Découvrir les carbets</a></p>`,
|
||||
),
|
||||
text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingRequestToTenant(
|
||||
to: string,
|
||||
firstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
amount: string,
|
||||
currency: string,
|
||||
): Promise<void> {
|
||||
const fmt = (d: Date) =>
|
||||
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Demande de réservation enregistrée — ${carbetTitle}`,
|
||||
html: wrap(
|
||||
"Demande de réservation envoyée",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre demande de réservation pour <strong>${carbetTitle}</strong> a bien été enregistrée :</p>
|
||||
<ul>
|
||||
<li>Arrivée : ${fmt(startDate)}</li>
|
||||
<li>Départ : ${fmt(endDate)}</li>
|
||||
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
|
||||
</ul>
|
||||
<p>Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.</p>
|
||||
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingRequestToOwner(
|
||||
to: string,
|
||||
ownerFirstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
tenantName: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<void> {
|
||||
const fmt = (d: Date) =>
|
||||
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Nouvelle demande de réservation — ${carbetTitle}`,
|
||||
html: wrap(
|
||||
"Nouvelle demande à confirmer",
|
||||
`<p>Bonjour ${ownerFirstName},</p>
|
||||
<p><strong>${tenantName}</strong> souhaite réserver <strong>${carbetTitle}</strong> :</p>
|
||||
<ul>
|
||||
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
|
||||
</ul>
|
||||
<p>Connectez-vous à votre espace hôte pour confirmer ou refuser.</p>
|
||||
<p><a href="${SITE_URL}/espace-hote" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mon espace hôte</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingConfirmed(
|
||||
to: string,
|
||||
firstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<void> {
|
||||
const fmt = (d: Date) =>
|
||||
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Réservation confirmée — ${carbetTitle}`,
|
||||
html: wrap(
|
||||
"Votre séjour est confirmé",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre réservation pour <strong>${carbetTitle}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.</p>
|
||||
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingRefunded(
|
||||
to: string,
|
||||
firstName: string,
|
||||
bookingId: string,
|
||||
carbetTitle: string,
|
||||
amount: string,
|
||||
currency: string,
|
||||
): Promise<void> {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Remboursement traité — ${carbetTitle}`,
|
||||
html: wrap(
|
||||
"Remboursement en cours",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre réservation pour <strong>${carbetTitle}</strong> a été annulée et le remboursement de <strong>${Number(amount).toFixed(2)} ${currency}</strong> est en cours de traitement par Stripe (3 à 5 jours ouvrés).</p>
|
||||
<p><a href="${SITE_URL}/reservations/${bookingId}" style="color:#18181b;">Détails de la réservation</a></p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue