Merge pull request 'feat/improve-emails' (#8) from feat/improve-emails into development
Reviewed-on: #8
This commit is contained in:
48
AGENTS.md
48
AGENTS.md
@@ -193,3 +193,51 @@ await getAvailability('my-club', { date: '2026-04-20' });
|
||||
1. Create `lib/api/resources/[resource].ts`
|
||||
2. Export functions using `http` from `../http`
|
||||
3. Add exports in `lib/api/index.ts`
|
||||
|
||||
## Email Templates
|
||||
|
||||
Todos los emails deben usar la misma estética. El layout compartido está en `apps/backend/src/emails/booking-confirmation.ts`.
|
||||
|
||||
### Layout (`wrapLayout`)
|
||||
|
||||
Exportado como `wrapLayout(content)`. Proporciona:
|
||||
- Fondo: `#edf7f4`
|
||||
- Card blanca: `max-width: 520px`, `border-radius: 28px`, `box-shadow: 0 24px 70px rgba(15,23,42,0.12)`, `border: 1px solid rgba(5,9,20,0.1)`
|
||||
- Playzer favicon: `{APP_BASE_URL}/playzer-favicon-512-transparent.png` (está en `apps/frontend/public/`)
|
||||
|
||||
```typescript
|
||||
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||
|
||||
const html = wrapLayout(`<tr>...contenido...</tr>`);
|
||||
```
|
||||
|
||||
### Estructura de cada email
|
||||
|
||||
| Sección | Descripción |
|
||||
|---------|-------------|
|
||||
| **Header** | Dos columnas: badge pill a la izquierda + Playzer (favicon + texto) a la derecha. El badge usa `border-radius: 999px`, `padding: 4px 12px`, `font-size: 11px`, `font-weight: 700`, `letter-spacing: 0.14em`, `text-transform: uppercase`. |
|
||||
| **Card fecha/hora** | Fondo `#f0fdf4`, borde `1px solid rgba(5,150,105,0.3)`, `border-radius: 24px`. Siempre verde aunque el email sea de cancelación. |
|
||||
| **Grilla detalles** | `border: 1px solid #e5e7eb`, `border-radius: 22px`, dos celdas de 50% con `border-right` en la primera. |
|
||||
| **Botón CTA** | Tabla con fondo `#059669`, `border-radius: 12px`, link blanco con `padding: 14px 32px`. |
|
||||
| **Pie** | Sin pie de marca. Solo texto secundario opcional centrado si es necesario. |
|
||||
| **Sin botones de acción** | Los emails de booking **no** incluyen los botones "Compartir por WhatsApp" ni "Hacer otra reserva". |
|
||||
|
||||
### Colores de badges según estado
|
||||
|
||||
| Estado | Fondo badge | Texto badge |
|
||||
|--------|-------------|-------------|
|
||||
| Confirmado | `#f0fdf4` | `#15803d` |
|
||||
| Cancelado | `#fef2f2` | `#dc2626` |
|
||||
| No concretado | `#fffbeb` | `#d97706` |
|
||||
| Neutro (verificación, etc.) | `#f4f4f5` | `#71717a` |
|
||||
|
||||
### Archivos de templates
|
||||
|
||||
| Archivo | Templates |
|
||||
|---------|-----------|
|
||||
| `apps/backend/src/emails/booking-confirmation.ts` | `bookingConfirmationHtml`, `bookingCancelledHtml`, `bookingNoShowHtml` + exporta `wrapLayout` |
|
||||
| `apps/backend/src/lib/auth.ts` | Email de verificación de Better Auth (usa `wrapLayout` inline) |
|
||||
|
||||
### Regla general
|
||||
|
||||
Para emails nuevos: importar `wrapLayout`, construir el HTML interno con `<tr>`s, usar la misma estructura de cabecera (badge + Playzer), y nunca incluir el pie "Playzer — Reserva de canchas online". Usar `APP_BASE_URL` para construir URLs absolutas al logo.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
* {
|
||||
@@ -35,7 +49,7 @@ const BASE_STYLES = `
|
||||
}
|
||||
`;
|
||||
|
||||
function wrapLayout(content: string) {
|
||||
export function wrapLayout(content: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
@@ -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>
|
||||
@@ -58,108 +72,111 @@ function wrapLayout(content: string) {
|
||||
</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 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} — ${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;">
|
||||
✓ 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} — ${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 — 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} — ${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ó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á el código de reserva al llegar al complejo.
|
||||
<br />
|
||||
Si necesitás cancelar o modificar, contactate directamente con el complejo.
|
||||
</p>
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -168,50 +185,99 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
|
||||
}
|
||||
|
||||
export function bookingCancelledHtml(data: BookingEmailData): string {
|
||||
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:#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;">
|
||||
<td style="padding:32px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)}
|
||||
${detailRow('Cliente', data.customerName)}
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#fef2f2;border-radius:999px;padding:4px 12px;">
|
||||
<tr>
|
||||
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#dc2626;text-transform:uppercase;line-height:1.25rem;">
|
||||
✗ Reserva cancelada
|
||||
</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>
|
||||
|
||||
<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
|
||||
<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;">
|
||||
<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} — ${data.endTime}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fef2f2;border-radius:24px;border:1px solid rgba(220,38,38,0.3);">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<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>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 32px;">
|
||||
<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} — ${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;">
|
||||
Cliente
|
||||
</p>
|
||||
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||
${data.customerName}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -219,70 +285,101 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
||||
}
|
||||
|
||||
export function bookingNoShowHtml(data: BookingEmailData): string {
|
||||
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:#d97706;text-align:center;">
|
||||
<td style="padding:32px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#fffbeb;border-radius:999px;padding:4px 12px;">
|
||||
<tr>
|
||||
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#d97706;text-transform:uppercase;line-height:1.25rem;">
|
||||
Reserva no concretada
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">
|
||||
</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>
|
||||
|
||||
<tr>
|
||||
<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;">
|
||||
<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} — ${data.endTime}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:20px 32px 12px;">
|
||||
${dateTimeBlock(data.date, data.startTime, data.endTime)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${divider()}
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<td style="padding:0 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fffbeb;border-radius:24px;border:1px solid rgba(217,119,6,0.3);">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<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
|
||||
<td style="padding:0 32px 32px;">
|
||||
<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} — ${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;">
|
||||
Cliente
|
||||
</p>
|
||||
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||
${data.customerName}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</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>`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||
import { dash } from '@better-auth/infra';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
@@ -34,10 +35,73 @@ export const auth = betterAuth({
|
||||
const verificationUrl = new URL(url);
|
||||
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
|
||||
verificationUrl.searchParams.set('callbackURL', appUrl);
|
||||
|
||||
const content = `
|
||||
<tr>
|
||||
<td style="padding:32px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f4f4f5;border-radius:999px;padding:4px 12px;">
|
||||
<tr>
|
||||
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#71717a;text-transform:uppercase;line-height:1.25rem;">
|
||||
Verificación de email
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h1 style="margin:16px 0 0;font-size:24px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
|
||||
Verificá tu dirección de correo electrónico
|
||||
</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="${appUrl}/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>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
|
||||
Hacé click en el botón de abajo para verificar tu dirección de correo electrónico y empezar a usar Playzer.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 32px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="background-color:#059669;border-radius:12px;text-align:center;">
|
||||
<a href="${verificationUrl.toString()}" style="display:block;padding:14px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;line-height:1.25rem;">Verificar email</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 32px 32px;">
|
||||
<p style="margin:0;font-size:13px;color:#9ca3af;text-align:center;line-height:1.5;">
|
||||
Si no creaste una cuenta en Playzer, ignorá este mensaje.
|
||||
</p>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: 'Verificá tu email en Playzer',
|
||||
html: `Hacé click para verificar tu email: <a href="${verificationUrl.toString()}">${verificationUrl.toString()}</a>`,
|
||||
html: wrapLayout(content),
|
||||
text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
BIN
apps/frontend/public/playzer-favicon-512-transparent.png
Normal file
BIN
apps/frontend/public/playzer-favicon-512-transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Reference in New Issue
Block a user