diff --git a/package-lock.json b/package-lock.json index f80d6c5..547438e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4b2d77d..e02165a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/admin/bookings/actions.ts b/src/app/admin/bookings/actions.ts index a8726d4..ca9e401 100644 --- a/src/app/admin/bookings/actions.ts +++ b/src/app/admin/bookings/actions.ts @@ -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) { 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 }; diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts index 11f94ad..8ada7f7 100644 --- a/src/app/api/bookings/route.ts +++ b/src/app/api/bookings/route.ts @@ -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 }); } diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index db2f49d..b1044b8 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -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 }); } diff --git a/src/app/carbets/_components/booking-form.tsx b/src/app/carbets/_components/booking-form.tsx index 2c4f1e2..c2d85a2 100644 --- a/src/app/carbets/_components/booking-form.tsx +++ b/src/app/carbets/_components/booking-form.tsx @@ -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(null); + const [blockedDates, setBlockedDates] = useState>(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(); + 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({ ) : null} + {hasConflict ? ( +
+ 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. +
+ ) : null} + + {upcomingBlocked.length > 0 && !hasConflict ? ( +
+ Voir les prochaines dates indisponibles +
+ {upcomingBlocked.map((d) => ( + + {d} + + ))} + {blockedDates.size > upcomingBlocked.length ? ( + + {blockedDates.size - upcomingBlocked.length} autres + ) : null} +
+
+ ) : null} + {error ? (
{error}
) : null} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..3712ee7 --- /dev/null +++ b/src/lib/email.ts @@ -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 { + 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é "; + +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 ` +
+

${title}

+ ${content} +
+

+ Karbé · ${SITE_URL}
+ Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le. +

+
+ `; +} + +export async function sendSignupWelcome(to: string, firstName: string): Promise { + await sendEmail({ + to, + subject: "Bienvenue sur Karbé", + html: wrap( + `Bienvenue ${firstName} !`, + `

Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.

+

Découvrir les carbets

`, + ), + 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 { + 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", + `

Bonjour ${firstName},

+

Votre demande de réservation pour ${carbetTitle} a bien été enregistrée :

+
    +
  • Arrivée : ${fmt(startDate)}
  • +
  • Départ : ${fmt(endDate)}
  • +
  • Montant : ${Number(amount).toFixed(2)} ${currency}
  • +
+

Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.

+

Voir ma réservation

`, + ), + }); +} + +export async function sendBookingRequestToOwner( + to: string, + ownerFirstName: string, + bookingId: string, + carbetTitle: string, + tenantName: string, + startDate: Date, + endDate: Date, +): Promise { + 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", + `

Bonjour ${ownerFirstName},

+

${tenantName} souhaite réserver ${carbetTitle} :

+
    +
  • Du ${fmt(startDate)} au ${fmt(endDate)}
  • +
+

Connectez-vous à votre espace hôte pour confirmer ou refuser.

+

Mon espace hôte

`, + ), + }); +} + +export async function sendBookingConfirmed( + to: string, + firstName: string, + bookingId: string, + carbetTitle: string, + startDate: Date, + endDate: Date, +): Promise { + 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é", + `

Bonjour ${firstName},

+

Votre réservation pour ${carbetTitle} du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.

+

Voir ma réservation

`, + ), + }); +} + +export async function sendBookingRefunded( + to: string, + firstName: string, + bookingId: string, + carbetTitle: string, + amount: string, + currency: string, +): Promise { + await sendEmail({ + to, + subject: `Remboursement traité — ${carbetTitle}`, + html: wrap( + "Remboursement en cours", + `

Bonjour ${firstName},

+

Votre réservation pour ${carbetTitle} a été annulée et le remboursement de ${Number(amount).toFixed(2)} ${currency} est en cours de traitement par Stripe (3 à 5 jours ouvrés).

+

Détails de la réservation

`, + ), + }); +}