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:
Jose Selesan
2026-06-02 19:21:04 -03:00
parent 50fa4ed9a5
commit 85f234b05e
22 changed files with 610 additions and 1536 deletions

View 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;">&nbsp;</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} &mdash; ${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} &mdash; ${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 &mdash; 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} &mdash; ${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 &mdash; 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} &mdash; ${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 &mdash; 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>`;
}