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
230
package-lock.json
generated
230
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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