feat/improve-emails #8
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`
|
1. Create `lib/api/resources/[resource].ts`
|
||||||
2. Export functions using `http` from `../http`
|
2. Export functions using `http` from `../http`
|
||||||
3. Add exports in `lib/api/index.ts`
|
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;
|
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 {
|
function formatFriendlyDate(isoDate: string): string {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||||
if (!match) return isoDate;
|
if (!match) return isoDate;
|
||||||
@@ -27,7 +41,7 @@ const BASE_STYLES = `
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #f4f4f5;
|
background-color: #edf7f4;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
@@ -44,11 +58,11 @@ function wrapLayout(content: string) {
|
|||||||
<title>Playzer</title>
|
<title>Playzer</title>
|
||||||
<style>${BASE_STYLES}</style>
|
<style>${BASE_STYLES}</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
<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:#f4f4f5;min-height:100vh;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#edf7f4;min-height:100vh;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding:32px 16px;">
|
<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}
|
${content}
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
@@ -58,108 +72,111 @@ function wrapLayout(content: string) {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerSection() {
|
export function bookingConfirmationHtml(data: BookingEmailData): string {
|
||||||
return `
|
const formattedPrice = formatBookingPrice(data.price ?? 0);
|
||||||
<tr>
|
const friendlyDate = formatFriendlyDate(data.date);
|
||||||
<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() {
|
const content = `
|
||||||
return `
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px;">
|
<td style="padding:32px 32px 24px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="height:1px;background-color:#e5e7eb;line-height:1px;font-size:1px;"> </td>
|
<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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>
|
||||||
}
|
|
||||||
|
|
||||||
function dateTimeBlock(date: string, startTime: string, endTime: string): string {
|
|
||||||
const friendlyDate = formatFriendlyDate(date);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px;">
|
<td style="padding:0 32px 24px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:10px;border:1px solid #bbf7d0;">
|
<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>
|
<tr>
|
||||||
<td style="padding:20px 24px;text-align:center;">
|
<td style="padding:20px 24px;">
|
||||||
<p style="margin:0;font-size:16px;font-weight:700;color:#065f46;line-height:1.4;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
${friendlyDate}
|
<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="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>
|
||||||
<p style="margin:8px 0 0;font-size:22px;font-weight:800;color:#059669;letter-spacing:-0.3px;">
|
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||||
${startTime} — ${endTime}
|
${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>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</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>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 32px 12px;">
|
<td style="padding:0 32px 32px;">
|
||||||
${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;">
|
<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.
|
Presentá el código de reserva al llegar al complejo.
|
||||||
<br />
|
<br />
|
||||||
Si necesitás cancelar o modificar, contactate directamente con el complejo.
|
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>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -168,50 +185,99 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bookingCancelledHtml(data: BookingEmailData): string {
|
export function bookingCancelledHtml(data: BookingEmailData): string {
|
||||||
|
const friendlyDate = formatFriendlyDate(data.date);
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${headerSection()}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:32px 32px 8px;">
|
<td style="padding:32px 32px 24px;">
|
||||||
<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">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)}
|
<tr>
|
||||||
${detailRow('Cliente', data.customerName)}
|
<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>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color:#f9fafb;padding:20px 32px;text-align:center;">
|
<td style="padding:0 32px 24px;">
|
||||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
<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);">
|
||||||
Playzer — Reserva de canchas online
|
<tr>
|
||||||
</p>
|
<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>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
@@ -219,70 +285,101 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bookingNoShowHtml(data: BookingEmailData): string {
|
export function bookingNoShowHtml(data: BookingEmailData): string {
|
||||||
|
const friendlyDate = formatFriendlyDate(data.date);
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${headerSection()}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:32px 32px 8px;">
|
<td style="padding:32px 32px 24px;">
|
||||||
<h1 style="margin:0;font-size:22px;font-weight:700;color:#d97706;text-align:center;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
Reserva no concretada
|
<tr>
|
||||||
</h1>
|
<td valign="top">
|
||||||
<p style="margin:8px 0 0;font-size:14px;color:#6b7280;text-align:center;">
|
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#fffbeb;border-radius:999px;padding:4px 12px;">
|
||||||
${data.complexName}
|
<tr>
|
||||||
</p>
|
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#d97706;text-transform:uppercase;line-height:1.25rem;">
|
||||||
</td>
|
Reserva no concretada
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
</table>
|
||||||
<td style="padding:20px 32px 12px;">
|
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
|
||||||
${dateTimeBlock(data.date, data.startTime, data.endTime)}
|
${data.complexName}
|
||||||
</td>
|
</h1>
|
||||||
</tr>
|
</td>
|
||||||
|
<td valign="top" align="right" style="white-space:nowrap;">
|
||||||
${divider()}
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
<tr>
|
<td valign="middle" style="padding-right:8px;">
|
||||||
<td style="padding:24px 32px;">
|
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
|
||||||
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">
|
</td>
|
||||||
La reserva <strong style="font-family:monospace;font-weight:700;letter-spacing:2px;">${data.bookingCode}</strong>
|
<td valign="middle">
|
||||||
fue registrada como no concretada por falta de asistencia.
|
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
|
||||||
</p>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<td style="padding:0 32px 24px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<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);">
|
||||||
${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)}
|
<tr>
|
||||||
${detailRow('Cliente', data.customerName)}
|
<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>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color:#f9fafb;padding:20px 32px;text-align:center;">
|
<td style="padding:0 32px 24px;">
|
||||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
<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);">
|
||||||
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>
|
<tr>
|
||||||
<td style="width:100px;font-size:13px;color:#6b7280;vertical-align:top;padding:2px 0;">
|
<td style="padding:20px 24px;">
|
||||||
${label}
|
<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>
|
</td>
|
||||||
<td style="font-size:13px;font-weight:600;color:#111827;vertical-align:top;padding:2px 0;">
|
</tr>
|
||||||
${value}
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
|
return wrapLayout(content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||||
import { dash } from '@better-auth/infra';
|
import { dash } from '@better-auth/infra';
|
||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||||
@@ -34,10 +35,73 @@ export const auth = betterAuth({
|
|||||||
const verificationUrl = new URL(url);
|
const verificationUrl = new URL(url);
|
||||||
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
|
const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173';
|
||||||
verificationUrl.searchParams.set('callbackURL', appUrl);
|
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({
|
await sendMail({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Verificá tu email en Playzer',
|
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()}`,
|
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).toBeInstanceOf(ComplexMembersError);
|
||||||
expect((caught as ComplexMembersError).status).toBe(409);
|
expect((caught as InstanceType<typeof ComplexMembersError>).status).toBe(409);
|
||||||
expect(transactionMock).not.toHaveBeenCalled();
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
expect(sendMailMock).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