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

230
package-lock.json generated
View file

@ -18,6 +18,7 @@
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"resend": "^4.8.0",
"stripe": "^18.3.0"
},
"devDependencies": {
@ -2231,6 +2232,24 @@
}
}
},
"node_modules/@react-email/render": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
"integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==",
"license": "MIT",
"dependencies": {
"html-to-text": "^9.0.5",
"prettier": "^3.5.3",
"react-promise-suspense": "^0.3.4"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -2238,6 +2257,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@smithy/core": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
@ -2711,7 +2743,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@ -4028,6 +4060,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@ -4121,6 +4162,61 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@ -4197,6 +4293,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
@ -5405,6 +5513,41 @@
"node": ">=16.9.0"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
@ -6067,6 +6210,15 @@
"node": ">=0.10"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -6915,6 +7067,19 @@
"node": ">=6"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -6964,6 +7129,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@ -7221,6 +7395,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prisma": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
@ -7388,6 +7577,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
"integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^2.0.1"
}
},
"node_modules/react-promise-suspense/node_modules/fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@ -7466,6 +7670,18 @@
"node": ">=0.10.0"
}
},
"node_modules/resend": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz",
"integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==",
"license": "MIT",
"dependencies": {
"@react-email/render": "1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@ -7623,6 +7839,18 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",

View file

@ -19,6 +19,7 @@
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"resend": "^4.8.0",
"stripe": "^18.3.0"
},
"devDependencies": {

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}

207
src/lib/email.ts Normal file
View 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 é 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 é 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 é 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>`,
),
});
}