diff --git a/AGENTS.md b/AGENTS.md index 7e9f804..552c6da 100644 --- a/AGENTS.md +++ b/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(`...contenido...`); +``` + +### 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 ``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. diff --git a/apps/backend/src/emails/booking-confirmation.ts b/apps/backend/src/emails/booking-confirmation.ts index 0aa7e42..f0a4994 100644 --- a/apps/backend/src/emails/booking-confirmation.ts +++ b/apps/backend/src/emails/booking-confirmation.ts @@ -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 ` @@ -44,11 +58,11 @@ function wrapLayout(content: string) { Playzer - - + +
@@ -58,108 +72,111 @@ function wrapLayout(content: string) { `; } -function headerSection() { - return ` - - - `; -} +export function bookingConfirmationHtml(data: BookingEmailData): string { + const formattedPrice = formatBookingPrice(data.price ?? 0); + const friendlyDate = formatFriendlyDate(data.date); -function divider() { - return ` + const content = ` - - `; -} + -function dateTimeBlock(date: string, startTime: string, endTime: string): string { - const friendlyDate = formatFriendlyDate(date); - - return ` -
- +
${content}
- Playzer -
+ - + +
  + + + + +
+ ✓ Reserva confirmada +
+

+ ${data.complexName} +

+
+ + + + + +
+ Playzer + + Playzer +
+
- + + + + + - `; -} - -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()} - - - - - - ${divider()} - - - - - - ${divider()} - - - - - - - `; @@ -168,50 +185,99 @@ export function bookingConfirmationHtml(data: BookingEmailData): string { } export function bookingCancelledHtml(data: BookingEmailData): string { + const friendlyDate = formatFriendlyDate(data.date); + const content = ` - ${headerSection()} - - - - - - - - ${divider()} - - - - - - - - + + + + + + + + `; @@ -219,70 +285,101 @@ export function bookingCancelledHtml(data: BookingEmailData): string { } export function bookingNoShowHtml(data: BookingEmailData): string { + const friendlyDate = formatFriendlyDate(data.date); + const content = ` - ${headerSection()} - - - - - - - - ${divider()} - - - - - `; - - return wrapLayout(content); -} - -function detailRow(label: string, value: string): string { - return ` - -
+ - + +
-

- ${friendlyDate} +

+ + + + + +
+

+ Tu turno +

+

+ ${friendlyDate} +

+

+ ${data.startTime} — ${data.endTime} +

+
+ + + + +
+

Precio del turno

+

${formattedPrice}

+
+
+
+
+ + + +
+

+ Cancha

-

- ${startTime} — ${endTime} +

+ ${data.courtName} — ${data.sportName} +

+
+

+ Código de reserva +

+

+ ${data.bookingCode}

-

- Reserva confirmada -

-

- ${data.complexName} -

-
- ${dateTimeBlock(data.date, data.startTime, data.endTime)} -
- - ${detailRow('Código', `${data.bookingCode}`)} - ${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)} - ${detailRow('Cliente', data.customerName)} - ${formattedPrice ? detailRow('Precio', formattedPrice) : ''} -
-
+

- Presentá el código de reserva al llegar al complejo. + Presentá el código de reserva al llegar al complejo.
- Si necesitás cancelar o modificar, contactate directamente con el complejo. -

-
-

- Playzer — Reserva de canchas online + Si necesitás cancelar o modificar, contactate directamente con el complejo.

-

- Reserva cancelada -

-

- ${data.complexName} -

-
- ${dateTimeBlock(data.date, data.startTime, data.endTime)} -
-

- La reserva ${data.bookingCode} - fue cancelada. Si tenés dudas, contactate con el complejo. -

-
+ - ${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)} - ${detailRow('Cliente', data.customerName)} + + + +
+ + + + +
+ ✗ Reserva cancelada +
+

+ ${data.complexName} +

+
+ + + + + +
+ Playzer + + Playzer +
+
-

- Playzer — Reserva de canchas online -

+
+ + + + +
+

+ Tu turno +

+

+ ${friendlyDate} +

+

+ ${data.startTime} — ${data.endTime} +

+
+
+ + + + +
+

+ La reserva ${data.bookingCode} + fue cancelada. Si tenés dudas, contactate con el complejo. +

+
+
+ + + + + +
+

+ Cancha +

+

+ ${data.courtName} — ${data.sportName} +

+
+

+ Cliente +

+

+ ${data.customerName} +

+
-

- Reserva no concretada -

-

- ${data.complexName} -

-
- ${dateTimeBlock(data.date, data.startTime, data.endTime)} -
-

- La reserva ${data.bookingCode} - fue registrada como no concretada por falta de asistencia. -

+
+ + + + + +
+ + + + +
+ Reserva no concretada +
+

+ ${data.complexName} +

+
+ + + + + +
+ Playzer + + Playzer +
+
- - ${detailRow('Cancha', `${data.courtName} — ${data.sportName}`)} - ${detailRow('Cliente', data.customerName)} +
+ + +
+

+ Tu turno +

+

+ ${friendlyDate} +

+

+ ${data.startTime} — ${data.endTime} +

+
-

- Playzer — Reserva de canchas online -

-
- + + + + + `; + + return wrapLayout(content); } diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index 240ce45..5c9b7e7 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -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 = ` + + + + + + + + + + + + + + + `; + await sendMail({ to: user.email, subject: 'Verificá tu email en Playzer', - html: `Hacé click para verificar tu email: ${verificationUrl.toString()}`, + html: wrapLayout(content), text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`, }); }, diff --git a/apps/backend/test/complex/invite-complex-user.test.ts b/apps/backend/test/complex/invite-complex-user.test.ts index 9d13302..839db55 100644 --- a/apps/backend/test/complex/invite-complex-user.test.ts +++ b/apps/backend/test/complex/invite-complex-user.test.ts @@ -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).status).toBe(409); expect(transactionMock).not.toHaveBeenCalled(); expect(sendMailMock).not.toHaveBeenCalled(); }); diff --git a/apps/frontend/public/playzer-favicon-512-transparent.png b/apps/frontend/public/playzer-favicon-512-transparent.png new file mode 100644 index 0000000..c4d6d05 Binary files /dev/null and b/apps/frontend/public/playzer-favicon-512-transparent.png differ
+ - - +
- ${label} + +

+ La reserva ${data.bookingCode} + fue registrada como no concretada por falta de asistencia. +

- ${value} +
+
+ + + +
+

+ Cancha +

+

+ ${data.courtName} — ${data.sportName} +

+
+

+ Cliente +

+

+ ${data.customerName} +

+ + + + + +
+ + + + +
+ Verificación de email +
+

+ Verificá tu dirección de correo electrónico +

+
+ + + + + +
+ Playzer + + Playzer +
+
+
+

+ Hacé click en el botón de abajo para verificar tu dirección de correo electrónico y empezar a usar Playzer. +

+
+ + + + +
+ Verificar email +
+
+

+ Si no creaste una cuenta en Playzer, ignorá este mensaje. +

+