feat/improve-emails #8

Merged
jselesan merged 4 commits from feat/improve-emails into development 2026-06-03 13:44:13 +00:00
3 changed files with 110 additions and 49 deletions
Showing only changes of commit ba7b0322ff - Show all commits

View File

@@ -10,6 +10,20 @@ type BookingEmailData = {
price?: number;
};
const APP_BASE_URL = process.env.APP_BASE_URL ?? 'http://localhost:5173';
function formatBookingPrice(price: number): string {
if (price === 0) {
return 'Sin cargo';
}
return new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
}).format(price);
}
function formatFriendlyDate(isoDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) return isoDate;
@@ -27,7 +41,7 @@ const BASE_STYLES = `
body {
margin: 0;
padding: 0;
background-color: #f4f4f5;
background-color: #edf7f4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
@@ -44,11 +58,11 @@ function wrapLayout(content: string) {
<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;">
<body style="margin:0;padding:0;background-color:#edf7f4;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:#edf7f4;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);">
<table role="presentation" width="100%" style="max-width:520px;background-color:#ffffff;border-radius:28px;overflow:hidden;box-shadow:0 24px 70px rgba(15,23,42,0.12);border:1px solid rgba(5,9,20,0.1);">
${content}
</table>
</td>
@@ -103,63 +117,110 @@ function dateTimeBlock(date: string, startTime: string, endTime: string): string
}
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 formattedPrice = formatBookingPrice(data.price ?? 0);
const friendlyDate = formatFriendlyDate(data.date);
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;">
<td style="padding:32px 32px 24px;">
<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) : ''}
<tr>
<td valign="top">
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f0fdf4;border-radius:999px;padding:4px 12px;">
<tr>
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#15803d;text-transform:uppercase;line-height:1.25rem;">
&#10003; Reserva confirmada
</td>
</tr>
</table>
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
${data.complexName}
</h1>
</td>
<td valign="top" align="right" style="white-space:nowrap;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td valign="middle" style="padding-right:8px;">
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
</td>
<td valign="middle">
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
</td>
</tr>
</table>
</td>
</tr>
</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.
<td style="padding:0 32px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:24px;border:1px solid rgba(5,150,105,0.3);">
<tr>
<td style="padding:20px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td valign="bottom">
<p style="margin:0;font-size:13px;font-weight:600;color:#15803d;">
Tu turno
</p>
<p style="margin:12px 0 0;font-size:28px;font-weight:900;color:#111827;line-height:1.1;letter-spacing:-0.025em;">
${friendlyDate}
</p>
<p style="margin:8px 0 0;font-size:24px;font-weight:900;color:#059669;line-height:1;letter-spacing:-0.025em;">
${data.startTime} &mdash; ${data.endTime}
</p>
</td>
<td valign="bottom" align="right" style="padding-left:16px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:rgba(255,255,255,0.8);border-radius:16px;border:1px solid rgba(5,150,105,0.2);">
<tr>
<td style="padding:12px 16px;text-align:right;min-width:120px;">
<p style="margin:0;font-size:11px;font-weight:500;color:#6b7280;">Precio del turno</p>
<p style="margin:4px 0 0;font-size:20px;font-weight:700;color:#111827;">${formattedPrice}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</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
<td style="padding:0 32px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
<tr>
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
Cancha
</p>
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
${data.courtName} &mdash; ${data.sportName}
</p>
</td>
<td width="50%" style="padding:16px;background-color:#ffffff;">
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
C&oacute;digo de reserva
</p>
<p style="margin:8px 0 0;font-family:monospace;font-size:16px;font-weight:700;letter-spacing:0.18em;color:#111827;">
${data.bookingCode}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:0 32px 32px;">
<p style="margin:0;font-size:13px;color:#6b7280;text-align:center;line-height:1.5;">
Present&aacute; el c&oacute;digo de reserva al llegar al complejo.
<br />
Si necesit&aacute;s cancelar o modificar, contactate directamente con el complejo.
</p>
</td>
</tr>`;

View File

@@ -80,7 +80,7 @@ test('rejects when the invite target already belongs to the complex', async () =
}
expect(caught).toBeInstanceOf(ComplexMembersError);
expect((caught as ComplexMembersError).status).toBe(409);
expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409);
expect(transactionMock).not.toHaveBeenCalled();
expect(sendMailMock).not.toHaveBeenCalled();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB