feat: add customer email functionality for booking confirmations
- Implemented email confirmation for admin and public bookings. - Added customerEmail field to booking schemas and services. - Created email templates for booking confirmation, cancellation, and no-show notifications. - Updated booking handlers to send emails upon booking creation and status updates. - Enhanced frontend forms to capture customer email during booking creation.
This commit is contained in:
288
apps/backend/src/emails/booking-confirmation.ts
Normal file
288
apps/backend/src/emails/booking-confirmation.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
type BookingEmailData = {
|
||||
bookingCode: string;
|
||||
complexName: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
courtName: string;
|
||||
sportName: string;
|
||||
customerName: string;
|
||||
price?: number;
|
||||
};
|
||||
|
||||
function formatFriendlyDate(isoDate: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!match) return isoDate;
|
||||
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
|
||||
const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'long' }).format(date);
|
||||
const day = date.getDate();
|
||||
const month = new Intl.DateTimeFormat('es-AR', { month: 'long' }).format(date);
|
||||
|
||||
return `${weekday.charAt(0).toUpperCase() + weekday.slice(1)}, ${day} de ${month}`;
|
||||
}
|
||||
|
||||
const BASE_STYLES = `
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
|
||||
function wrapLayout(content: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playzer</title>
|
||||
<style>${BASE_STYLES}</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;min-height:100vh;">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" width="100%" style="max-width:520px;background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
${content}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function headerSection() {
|
||||
return `
|
||||
<tr>
|
||||
<td style="background-color:#0a1628;padding:32px 32px 28px;text-align:center;">
|
||||
<span style="font-size:24px;font-weight:700;color:#10b981;letter-spacing:-0.3px;">Playzer</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function divider() {
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding:0 32px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="height:1px;background-color:#e5e7eb;line-height:1px;font-size:1px;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function dateTimeBlock(date: string, startTime: string, endTime: string): string {
|
||||
const friendlyDate = formatFriendlyDate(date);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding:0 32px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:10px;border:1px solid #bbf7d0;">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;text-align:center;">
|
||||
<p style="margin:0;font-size:16px;font-weight:700;color:#065f46;line-height:1.4;">
|
||||
${friendlyDate}
|
||||
</p>
|
||||
<p style="margin:8px 0 0;font-size:22px;font-weight:800;color:#059669;letter-spacing:-0.3px;">
|
||||
${startTime} — ${endTime}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
export function bookingConfirmationHtml(data: BookingEmailData): string {
|
||||
const formattedPrice =
|
||||
data.price !== undefined && data.price > 0
|
||||
? new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
maximumFractionDigits: Number.isInteger(data.price) ? 0 : 2,
|
||||
}).format(data.price)
|
||||
: undefined;
|
||||
|
||||
const content = `
|
||||
${headerSection()}
|
||||
<tr>
|
||||
<td style="padding:32px 32px 8px;">
|
||||
<h1 style="margin:0;font-size:22px;font-weight:700;color:#111827;text-align:center;">
|
||||
Reserva confirmada
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">
|
||||
${data.complexName}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:20px 32px 12px;">
|
||||
${dateTimeBlock(data.date, data.startTime, data.endTime)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${divider()}
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
${detailRow('Código', `<span style="font-family:monospace;font-weight:700;letter-spacing:2px;">${data.bookingCode}</span>`)}
|
||||
${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)}
|
||||
${detailRow('Cliente', data.customerName)}
|
||||
${formattedPrice ? detailRow('Precio', formattedPrice) : ''}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${divider()}
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p style="margin:0;font-size:13px;color:#6b7280;text-align:center;line-height:1.5;">
|
||||
Presentá el código de reserva al llegar al complejo.
|
||||
<br />
|
||||
Si necesitás cancelar o modificar, contactate directamente con el complejo.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="background-color:#f9fafb;padding:20px 32px;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
Playzer — Reserva de canchas online
|
||||
</p>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
return wrapLayout(content);
|
||||
}
|
||||
|
||||
export function bookingCancelledHtml(data: BookingEmailData): string {
|
||||
const content = `
|
||||
${headerSection()}
|
||||
<tr>
|
||||
<td style="padding:32px 32px 8px;">
|
||||
<h1 style="margin:0;font-size:22px;font-weight:700;color:#dc2626;text-align:center;">
|
||||
Reserva cancelada
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">
|
||||
${data.complexName}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:20px 32px 12px;">
|
||||
${dateTimeBlock(data.date, data.startTime, data.endTime)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${divider()}
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">
|
||||
La reserva <strong style="font-family:monospace;font-weight:700;letter-spacing:2px;">${data.bookingCode}</strong>
|
||||
fue cancelada. Si tenés dudas, contactate con el complejo.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)}
|
||||
${detailRow('Cliente', data.customerName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="background-color:#f9fafb;padding:20px 32px;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
Playzer — Reserva de canchas online
|
||||
</p>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
return wrapLayout(content);
|
||||
}
|
||||
|
||||
export function bookingNoShowHtml(data: BookingEmailData): string {
|
||||
const content = `
|
||||
${headerSection()}
|
||||
<tr>
|
||||
<td style="padding:32px 32px 8px;">
|
||||
<h1 style="margin:0;font-size:22px;font-weight:700;color:#d97706;text-align:center;">
|
||||
Reserva no concretada
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">
|
||||
${data.complexName}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:20px 32px 12px;">
|
||||
${dateTimeBlock(data.date, data.startTime, data.endTime)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${divider()}
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">
|
||||
La reserva <strong style="font-family:monospace;font-weight:700;letter-spacing:2px;">${data.bookingCode}</strong>
|
||||
fue registrada como no concretada por falta de asistencia.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)}
|
||||
${detailRow('Cliente', data.customerName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="background-color:#f9fafb;padding:20px 32px;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
Playzer — Reserva de canchas online
|
||||
</p>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
return wrapLayout(content);
|
||||
}
|
||||
|
||||
function detailRow(label: string, value: string): string {
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding:6px 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="width:100px;font-size:13px;color:#6b7280;vertical-align:top;padding:2px 0;">
|
||||
${label}
|
||||
</td>
|
||||
<td style="font-size:13px;font-weight:600;color:#111827;vertical-align:top;padding:2px 0;">
|
||||
${value}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
Reference in New Issue
Block a user