diff --git a/docs/sistema-eventos.md b/docs/sistema-eventos.md index a3e438f2..73a043b2 100644 --- a/docs/sistema-eventos.md +++ b/docs/sistema-eventos.md @@ -56,25 +56,17 @@ Representa la inscripción de un usuario a un evento. ```prisma model EventRegistration { - id String @id @default(cuid()) - eventId String - firstName String - lastName String - email String - type RegistrationType - workTitle String? - workPlace String? - studyField String? - studyPlace String? - userId String? - cancelledAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - - @@unique([eventId, email]) + id String @id @default(cuid()) + eventId String + userId String // Usuario registrado (requerido) + cancelledAt DateTime? // Fecha de cancelación (si fue cancelada) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([eventId, userId]) @@index([eventId]) @@index([userId]) @@index([cancelledAt]) @@ -83,40 +75,53 @@ model EventRegistration { **Campos importantes:** -- `type`: Tipo de registro (`STUDENT` o `PROFESSIONAL`) - `cancelledAt`: Si tiene valor, la inscripción está cancelada (no se cuenta para cupos) -- `userId`: Opcional. Si el usuario está logueado, se vincula su cuenta. -- Campos condicionales: - - `workTitle` y `workPlace`: Requeridos si `type === 'PROFESSIONAL'` - - `studyField` y `studyPlace`: Requeridos si `type === 'STUDENT'` +- `@@unique([eventId, userId])`: Un usuario solo puede tener una inscripción por evento. Las inscripciones canceladas se reactivan en lugar de crear duplicados. -### Sponsor +### WaitlistEntry -Representa un patrocinador de un evento. +Representa la entrada de un usuario a la lista de espera de un evento. ```prisma -model Sponsor { +model WaitlistEntry { id String @id @default(cuid()) eventId String - name String - website String? + userId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([eventId, userId]) @@index([eventId]) + @@index([userId]) + @@index([createdAt]) } ``` -### RegistrationType +**Campos importantes:** -Enum que define el tipo de inscripción. +- `createdAt`: Determina el orden FIFO de la lista de espera +- `@@unique([eventId, userId])`: Un usuario solo puede tener una entrada por evento +- Las entradas se eliminan físicamente cuando el usuario es promovido o sale voluntariamente (no hay soft delete) + +### Sponsor + +Representa un patrocinador de un evento. ```prisma -enum RegistrationType { - STUDENT - PROFESSIONAL +model Sponsor { + id String @id @default(cuid()) + eventId String + name String + website String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@index([eventId]) } ``` @@ -156,10 +161,20 @@ enum RegistrationType { ### 5. Visualización - **Página de detalle**: Muestra toda la información del evento -- **Lista de inscripciones (Admin)**: Los administradores pueden ver todas las inscripciones +- **Lista de inscripciones (Admin)**: Los administradores pueden ver todas las inscripciones y la lista de espera - **Información de cupo**: Se muestra cuántos cupos quedan disponibles - **Mapa**: Si hay coordenadas, se muestra un mapa con la ubicación +### 6. Lista de Espera + +- **Unirse a la lista**: Cuando el cupo está completo, los usuarios pueden unirse a la lista de espera +- **Orden FIFO**: La lista de espera es ordenada por fecha de ingreso (el primero en entrar es el primero en ser promovido) +- **Promoción automática**: Cuando se cancela una inscripción, el primer usuario de la lista de espera es inscrito automáticamente +- **Notificación por email**: El usuario promovido recibe un email informando que tiene un lugar confirmado +- **Notificación a admins**: Los admins son notificados tanto de la cancelación como de la promoción +- **Salir de la lista**: Los usuarios pueden abandonar la lista de espera en cualquier momento +- **Protección ante condiciones de carrera**: Todas las operaciones utilizan transacciones con bloqueo de fila (`SELECT ... FOR UPDATE`) para evitar promociones duplicadas o inscripciones incorrectas cuando múltiples usuarios operan simultáneamente + --- ## Rutas y Páginas @@ -289,40 +304,56 @@ enum RegistrationType { #### `register-event.ts` - **Ruta**: `src/actions/events/register-event.ts` -- **Función**: `registerEvent(eventId: string, data: EventRegistrationFormData)` -- **Descripción**: Registra un usuario a un evento -- **Validaciones**: +- **Función**: `registerEvent(eventId: string, options?: { skipRedirect?: boolean })` +- **Descripción**: Registra un usuario a un evento. Si el cupo está completo, lo agrega a la lista de espera. +- **Validaciones** (dentro de una transacción con `FOR UPDATE`): - Verifica que el evento exista y no esté eliminado - - Verifica que no haya una inscripción activa con el mismo email + - Verifica que no haya una inscripción activa para el usuario + - Verifica que el usuario no esté ya en la lista de espera activa - Valida el cupo disponible (excluyendo cancelados) - **Lógica especial**: - - Si existe una inscripción cancelada con el mismo email, la reactiva en lugar de crear una nueva -- **Retorno**: Redirige a la página de detalle del evento + - Si existe una inscripción o entrada de lista de espera cancelada, la reactiva en lugar de crear una nueva + - Si hay cupo: inscribe directamente → devuelve `{ status: 'registered' }` + - Si no hay cupo: agrega a lista de espera → devuelve `{ status: 'waitlisted' }` +- **Retorno**: `{ success: true, registrationId?, status: 'registered' | 'waitlisted' }` o redirige #### `cancel-registration.ts` - **Ruta**: `src/actions/events/cancel-registration.ts` - **Función**: `cancelRegistration(params: CancelRegistrationParams)` -- **Descripción**: Cancela una inscripción (marca como cancelada, no elimina) +- **Descripción**: Cancela una inscripción y, si hay lista de espera, promueve automáticamente al primer usuario - **Parámetros**: - - `registrationId`: Si el usuario está logueado + - `registrationId`: ID de la inscripción (opcional) - `eventId`: ID del evento - - `email`: Si el usuario no está logueado -- **Validaciones**: - - Si hay `registrationId`, verifica que pertenezca al usuario logueado - - Si hay `email`, busca la inscripción por email - - Verifica que la inscripción no esté ya cancelada -- **Efecto**: Marca `cancelledAt` con la fecha actual +- **Efecto** (dentro de una transacción con `FOR UPDATE`): + 1. Marca la inscripción con `cancelledAt = now()` + 2. Si el evento tiene capacidad definida, busca el primer usuario en la lista de espera + 3. Si lo encuentra: marca la entrada de lista de espera como cancelada y crea/reactiva su inscripción + 4. Envía email al usuario promovido notificándole que tiene lugar confirmado #### `delete-registration.ts` - **Ruta**: `src/actions/events/delete-registration.ts` - **Función**: `deleteRegistration(registrationId: string)` -- **Descripción**: Elimina físicamente una inscripción (solo admins) +- **Descripción**: Elimina físicamente una inscripción activa (solo admins) y promueve al primer usuario de la lista de espera - **Validaciones**: - Verifica que el usuario sea administrador - Verifica que la inscripción exista -- **Efecto**: Elimina el registro de la base de datos (libera el cupo) +- **Efecto** (dentro de una transacción con `FOR UPDATE`): + 1. Elimina el registro de la base de datos + 2. Si la inscripción era activa y el evento tiene capacidad, promueve al primer usuario de la lista de espera + 3. Envía email al usuario promovido + +#### `cancel-waitlist.ts` + +- **Ruta**: `src/actions/events/cancel-waitlist.ts` +- **Función**: `cancelWaitlist(params: CancelWaitlistParams)` +- **Descripción**: Permite a un usuario salir voluntariamente de la lista de espera +- **Parámetros**: + - `eventId`: ID del evento +- **Efecto** (dentro de una transacción con `FOR UPDATE`): + - Marca la entrada de lista de espera con `cancelledAt = now()` + - Notifica a los admins ### Cupos @@ -401,6 +432,22 @@ enum RegistrationType { - `registrationId`: ID de la inscripción - `userName`: Nombre del usuario (para mostrar en confirmación) +#### `CancelWaitlistButton` + +- **Ruta**: `src/components/events/cancel-waitlist-button.tsx` +- **Descripción**: Botón para salir de la lista de espera +- **Características**: + - Llama a `cancelWaitlist()` con feedback de toast + - Refresca la página después de salir + +#### `WaitlistSuccessDialog` + +- **Ruta**: `src/components/events/waitlist-success-dialog.tsx` +- **Descripción**: Diálogo de confirmación al unirse a la lista de espera +- **Características**: + - Informa al usuario que será inscrito automáticamente cuando se libere un lugar + - Se muestra al unirse exitosamente a la lista + #### `DeleteEventButton` - **Ruta**: `src/components/events/delete-event-button.tsx` @@ -475,11 +522,22 @@ enum RegistrationType { #### Inscripciones -1. No se puede inscribir dos veces con el mismo email (inscripciones activas) +1. No se puede inscribir dos veces (por usuario, inscripciones activas) 2. Si un usuario canceló, puede volver a inscribirse (se reactiva la inscripción) 3. Las inscripciones canceladas no se eliminan, solo se marcan con `cancelledAt` 4. Solo los administradores pueden eliminar inscripciones físicamente +#### Lista de Espera + +1. Solo disponible para eventos con `capacity` definido (no `null`) +2. Un usuario no puede estar en la lista de espera y en inscripciones activas al mismo tiempo +3. La lista sigue orden FIFO por `createdAt` +4. Cuando se libera un cupo (cancelación o eliminación de inscripción activa), el primer usuario de la lista es promovido automáticamente +5. La promoción es atómica: usa `prisma.$transaction` con `SELECT ... FOR UPDATE` para evitar condiciones de carrera +6. El usuario promovido recibe un email de notificación +7. Al salir de la lista de espera voluntariamente no se libera ningún cupo (no activa promociones) +8. Las entradas de `WaitlistEntry` se eliminan físicamente al ser promovidas o al salir el usuario (no hay soft delete) + #### Eventos 1. Los eventos eliminados (`deletedAt !== null`) no se muestran en listados públicos @@ -570,6 +628,28 @@ enum RegistrationType { - Se libera el cupo - Se actualiza la lista +### Flujo: Unirse a la Lista de Espera + +1. Usuario navega a `/eventos/[id]` +2. El evento tiene cupo completo → Ve botón "Unirme a la lista de espera" y cantidad de personas en espera +3. Si no está autenticado: redirige al login con `autoRegister=true`; al volver, la acción lo coloca automáticamente en la lista de espera +4. Si está autenticado: llama a `registerEvent()` → el servidor detecta cupo completo y crea `WaitlistEntry` +5. Se muestra diálogo de confirmación: "Te inscribiremos automáticamente cuando se libere un lugar" +6. La página muestra ahora el estado "En lista de espera" con la posición del usuario y botón "Salir de la lista de espera" + +### Flujo: Promoción Automática desde Lista de Espera + +1. Un usuario cancela su inscripción (o un admin la elimina) +2. El servidor, dentro de una transacción atómica: + a. Cancela/elimina la inscripción + b. Busca la primera entrada activa en `WaitlistEntry` ordenada por `createdAt` + c. Marca esa entrada como cancelada (consumida) + d. Crea o reactiva una `EventRegistration` para el usuario promovido +3. Fuera de la transacción: + a. Notifica a los admins sobre la cancelación y la promoción + b. Envía email al usuario promovido: "¡Conseguiste un lugar! Tu inscripción está confirmada." +4. El usuario promovido, al recargar la página del evento, verá que ya está inscrito + ### Flujo: Editar un Evento (Admin) 1. Admin navega a `/eventos/[id]` @@ -643,12 +723,7 @@ enum RegistrationType { - Recordatorio antes del evento - Notificación de cancelación -2. **Lista de espera**: - - - Si el cupo está completo, permitir inscribirse en lista de espera - - Notificar cuando se libere un cupo - -3. **Exportación de datos**: +2. **Exportación de datos**: - Exportar lista de inscripciones a CSV/Excel - Filtros y búsqueda en la lista de inscripciones @@ -679,4 +754,4 @@ enum RegistrationType { --- -**Última actualización**: Diciembre 2024 +**Última actualización**: Abril 2026 diff --git a/prisma/migrations/20260410065947_add_waitlist_entry/migration.sql b/prisma/migrations/20260410065947_add_waitlist_entry/migration.sql new file mode 100644 index 00000000..81c8d759 --- /dev/null +++ b/prisma/migrations/20260410065947_add_waitlist_entry/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "WaitlistEntry" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WaitlistEntry_eventId_idx" ON "WaitlistEntry"("eventId"); + +-- CreateIndex +CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId"); + +-- CreateIndex +CREATE INDEX "WaitlistEntry_createdAt_idx" ON "WaitlistEntry"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "WaitlistEntry_eventId_userId_key" ON "WaitlistEntry"("eventId", "userId"); + +-- AddForeignKey +ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 16fe60c9..04c7e24d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,7 +38,8 @@ model User { comments Comment[] likes Like[] eventRegistrations EventRegistration[] - pageVisits PageVisit[] + waitlistEntries WaitlistEntry[] + pageVisits PageVisit[] testimonials Testimonial[] notifications Notification[] errorLogs ErrorLog[] @@ -123,10 +124,11 @@ model Event { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - images Image[] - registrations EventRegistration[] - sponsors Sponsor[] - announcements Announcement[] + images Image[] + registrations EventRegistration[] + waitlistEntries WaitlistEntry[] + sponsors Sponsor[] + announcements Announcement[] } model Image { @@ -202,6 +204,22 @@ model EventRegistration { @@index([cancelledAt]) } +model WaitlistEntry { + id String @id @default(cuid()) + eventId String + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([eventId, userId]) + @@index([eventId]) + @@index([userId]) + @@index([createdAt]) +} + model Sponsor { id String @id @default(cuid()) eventId String diff --git a/src/actions/events/cancel-registration.ts b/src/actions/events/cancel-registration.ts index bd56977e..f703f59c 100644 --- a/src/actions/events/cancel-registration.ts +++ b/src/actions/events/cancel-registration.ts @@ -1,9 +1,12 @@ 'use server'; +import { notifyAdmins } from '@/actions/notifications/notify-admins'; +import { WaitlistPromotionEmail } from '@/components/events/waitlist-promotion-email'; +import { sendEmail } from '@/lib/email'; import prisma from '@/lib/prisma'; +import { render } from '@react-email/render'; import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; -import { notifyAdmins } from '@/actions/notifications/notify-admins'; type CancelRegistrationParams = { registrationId?: string; @@ -13,7 +16,7 @@ type CancelRegistrationParams = { export const cancelRegistration = async (params: CancelRegistrationParams) => { const { registrationId, eventId } = params; - // Verificar autenticación + // Verificar autenticación antes de la transacción const sessionId = cookies().get('sessionId')?.value; if (!sessionId) { throw new Error('No autorizado'); @@ -28,72 +31,158 @@ export const cancelRegistration = async (params: CancelRegistrationParams) => { throw new Error('No autorizado'); } - // Verificar que el evento existe - const event = await prisma.event.findFirst({ - where: { - id: eventId, - deletedAt: null, - }, - }); + type PromotedUser = { + name: string; + email: string; + }; - if (!event) { - throw new Error('Evento no encontrado'); - } + const result = await prisma.$transaction( + async (tx) => { + // Bloquear el evento para evitar condicion de carrera + const events = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM "Event" WHERE id = ${eventId} AND "deletedAt" IS NULL FOR UPDATE + `; - let registration = null; + if (events.length === 0) { + throw new Error('Evento no encontrado'); + } - // Si hay registrationId, verificar que pertenece al usuario - if (registrationId) { - registration = await prisma.eventRegistration.findFirst({ - where: { - id: registrationId, - eventId: eventId, - userId: session.userId, - cancelledAt: null, // Solo cancelar si no está ya cancelada - }, - include: { user: true }, - }); - } else { - // Buscar inscripción del usuario actual - registration = await prisma.eventRegistration.findFirst({ - where: { - eventId: eventId, - userId: session.userId, - cancelledAt: null, - }, - include: { user: true }, - }); - } + const event = await tx.event.findUnique({ where: { id: eventId } }); + if (!event) throw new Error('Evento no encontrado'); - if (!registration) { - throw new Error('Inscripción no encontrada o ya cancelada'); - } + // Buscar la inscripción a cancelar + let registration = null; + if (registrationId) { + + registration = await tx.eventRegistration.findFirst({ + where: { + id: registrationId, + eventId, + userId: session.userId, + cancelledAt: null, + }, + include: { user: true }, + }); + } else { + registration = await tx.eventRegistration.findFirst({ + where: { eventId, userId: session.userId, cancelledAt: null }, + include: { user: true }, + }); + } + + if (!registration) { + throw new Error('Inscripción no encontrada o ya cancelada'); + } - const userName = registration.user.name; - const userEmail = registration.user.email; + // Cancelar la inscripción + await tx.eventRegistration.update({ + where: { id: registration.id }, + data: { cancelledAt: new Date() }, + }); - // Marcar como cancelada (no eliminar) - await prisma.eventRegistration.update({ - where: { id: registration.id }, - data: { - cancelledAt: new Date(), + // Si el evento tiene capacidad limitada, promover al primero en la lista de espera + let promotedUser: PromotedUser | null = null; + if (event.capacity !== null) { + const nextInWaitlist = await tx.waitlistEntry.findFirst({ + where: { eventId }, + orderBy: { createdAt: 'asc' }, + include: { user: true }, + }); + + if (nextInWaitlist) { + // Eliminar la entrada de la lista de espera (el usuario fue promovido) + await tx.waitlistEntry.delete({ + where: { id: nextInWaitlist.id }, + }); + + // Crear o reactivar la inscripción del usuario promovido + const existingRegistration = await tx.eventRegistration.findFirst({ + where: { eventId, userId: nextInWaitlist.userId }, + }); + + if (existingRegistration) { + await tx.eventRegistration.update({ + where: { id: existingRegistration.id }, + data: { cancelledAt: null }, + }); + } else { + await tx.eventRegistration.create({ + data: { eventId, userId: nextInWaitlist.userId }, + }); + } + + promotedUser = { + name: nextInWaitlist.user.name, + email: nextInWaitlist.user.email, + }; + } + } + + return { + registrationId: registration.id, + userName: registration.user.name, + userEmail: registration.user.email, + eventName: event.name, + eventDate: new Date(event.date).toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }), + promotedUser, + }; }, - }); + { timeout: 10000 }, + ); - // Notificar a los admins sobre la cancelación + // Notificaciones y emails fuera de la transacción await notifyAdmins({ type: 'event_registration_cancelled', title: 'Inscripción cancelada', - message: `${userName} ha cancelado su inscripción al evento "${event.name}"`, + message: `${result.userName} ha cancelado su inscripción al evento "${result.eventName}"`, metadata: { - eventId: eventId, - eventName: event.name, - registrationId: registration.id, - userName: userName, - userEmail: userEmail, + eventId, + eventName: result.eventName, + registrationId: result.registrationId, + userName: result.userName, + userEmail: result.userEmail, }, }); + if (result.promotedUser) { + await notifyAdmins({ + type: 'event_waitlist_promoted', + title: 'Usuario promovido desde lista de espera', + message: `${result.promotedUser.name} fue inscrito automáticamente al evento "${result.eventName}" desde la lista de espera`, + metadata: { + eventId, + eventName: result.eventName, + userName: result.promotedUser.name, + userEmail: result.promotedUser.email, + }, + }); + + // Enviar email al usuario promovido + try { + const emailHtml = await render( + WaitlistPromotionEmail({ + userName: result.promotedUser.name, + eventName: result.eventName, + eventDate: result.eventDate, + eventId, + }), + ); + await sendEmail({ + to: result.promotedUser.email, + subject: `Te has inscrito al evento ${result.eventName} - Programa Con Nosotros`, + html: emailHtml, + }); + } catch (error) { + console.error('Error al enviar email de promoción desde lista de espera:', error); + } + } + revalidatePath(`/eventos/${eventId}`); return { success: true }; }; diff --git a/src/actions/events/cancel-waitlist.ts b/src/actions/events/cancel-waitlist.ts new file mode 100644 index 00000000..946bbcba --- /dev/null +++ b/src/actions/events/cancel-waitlist.ts @@ -0,0 +1,55 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; +import { notifyAdmins } from '@/actions/notifications/notify-admins'; + +type CancelWaitlistParams = { + eventId: string; +}; + +export const cancelWaitlist = async (params: CancelWaitlistParams) => { + const { eventId } = params; + + // Verificar autenticación + const sessionId = cookies().get('sessionId')?.value; + if (!sessionId) { + throw new Error('No autorizado'); + } + + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + include: { user: true }, + }); + + if (!session?.user) { + throw new Error('No autorizado'); + } + + const waitlistEntry = await prisma.waitlistEntry.findFirst({ + where: { eventId, userId: session.userId }, + include: { event: { select: { name: true, deletedAt: true } } }, + }); + + if (!waitlistEntry || waitlistEntry.event.deletedAt !== null) { + throw new Error('No estás en la lista de espera de este evento'); + } + + await prisma.waitlistEntry.delete({ where: { id: waitlistEntry.id } }); + + await notifyAdmins({ + type: 'event_waitlist_cancelled', + title: 'Salida de lista de espera', + message: `${session.user.name} salió de la lista de espera del evento "${waitlistEntry.event.name}"`, + metadata: { + eventId, + eventName: waitlistEntry.event.name, + userName: session.user.name, + userEmail: session.user.email, + }, + }); + + revalidatePath(`/eventos/${eventId}`); + return { success: true }; +}; diff --git a/src/actions/events/check-event-capacity.ts b/src/actions/events/check-event-capacity.ts index 4174e2b3..5d1d9410 100644 --- a/src/actions/events/check-event-capacity.ts +++ b/src/actions/events/check-event-capacity.ts @@ -28,10 +28,15 @@ export const checkEventCapacity = async (eventId: string) => { const available = currentRegistrations < event.capacity; + const waitlistCount = await prisma.waitlistEntry.count({ + where: { eventId }, + }); + return { available, current: currentRegistrations, capacity: event.capacity, + waitlistCount, message: available ? `Quedan ${event.capacity - currentRegistrations} lugares disponibles.` : 'El cupo del evento está completo', diff --git a/src/actions/events/delete-registration.ts b/src/actions/events/delete-registration.ts index 5dfea9ec..d0e8d223 100644 --- a/src/actions/events/delete-registration.ts +++ b/src/actions/events/delete-registration.ts @@ -3,6 +3,10 @@ import prisma from '@/lib/prisma'; import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; +import { notifyAdmins } from '@/actions/notifications/notify-admins'; +import { sendEmail } from '@/lib/email'; +import { render } from '@react-email/render'; +import { WaitlistPromotionEmail } from '@/components/events/waitlist-promotion-email'; export const deleteRegistration = async (registrationId: string) => { // Verificar que el usuario es admin @@ -20,20 +24,116 @@ export const deleteRegistration = async (registrationId: string) => { throw new Error('No tienes permisos para realizar esta acción'); } - // Obtener la inscripción para saber el eventId + // Obtener la inscripción para saber el eventId antes de la transacción const registration = await prisma.eventRegistration.findUnique({ where: { id: registrationId }, + include: { user: true }, }); if (!registration) { throw new Error('Inscripción no encontrada'); } - // Eliminar la inscripción físicamente - await prisma.eventRegistration.delete({ - where: { id: registrationId }, - }); + const { eventId } = registration; + + type PromotedUser = { name: string; email: string }; + + const result = await prisma.$transaction( + async (tx) => { + // Bloquear el evento para serializar operaciones concurrentes + await tx.$queryRaw` + SELECT id FROM "Event" WHERE id = ${eventId} FOR UPDATE + `; + + const event = await tx.event.findUnique({ where: { id: eventId } }); + if (!event) throw new Error('Evento no encontrado'); + + // Eliminar la inscripción físicamente + await tx.eventRegistration.delete({ where: { id: registrationId } }); + + // Si el evento tiene capacidad limitada y la inscripción era activa, promover al primero en la lista de espera + let promotedUser: PromotedUser | null = null; + if (event.capacity !== null && registration.cancelledAt === null) { + const nextInWaitlist = await tx.waitlistEntry.findFirst({ + where: { eventId }, + orderBy: { createdAt: 'asc' }, + include: { user: true }, + }); + + if (nextInWaitlist) { + await tx.waitlistEntry.delete({ + where: { id: nextInWaitlist.id }, + }); + + const existingRegistration = await tx.eventRegistration.findFirst({ + where: { eventId, userId: nextInWaitlist.userId }, + }); + + if (existingRegistration) { + await tx.eventRegistration.update({ + where: { id: existingRegistration.id }, + data: { cancelledAt: null }, + }); + } else { + await tx.eventRegistration.create({ + data: { eventId, userId: nextInWaitlist.userId }, + }); + } + + promotedUser = { + name: nextInWaitlist.user.name, + email: nextInWaitlist.user.email, + }; + } + } + + return { + eventName: event.name, + eventDate: new Date(event.date).toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }), + promotedUser, + }; + }, + { timeout: 10000 }, + ); + + if (result.promotedUser) { + await notifyAdmins({ + type: 'event_waitlist_promoted', + title: 'Usuario promovido desde lista de espera', + message: `${result.promotedUser.name} fue inscrito automáticamente al evento "${result.eventName}" desde la lista de espera`, + metadata: { + eventId, + eventName: result.eventName, + userName: result.promotedUser.name, + userEmail: result.promotedUser.email, + }, + }); + + try { + const emailHtml = await render( + WaitlistPromotionEmail({ + userName: result.promotedUser.name, + eventName: result.eventName, + eventDate: result.eventDate, + eventId, + }), + ); + await sendEmail({ + to: result.promotedUser.email, + subject: `Te has inscrito al evento ${result.eventName} - Programa Con Nosotros`, + html: emailHtml, + }); + } catch (error) { + console.error('Error al enviar email de promoción desde lista de espera:', error); + } + } - revalidatePath(`/eventos/${registration.eventId}`); + revalidatePath(`/eventos/${eventId}`); return { success: true }; }; diff --git a/src/actions/events/register-event.ts b/src/actions/events/register-event.ts index de136532..119fa9df 100644 --- a/src/actions/events/register-event.ts +++ b/src/actions/events/register-event.ts @@ -6,20 +6,11 @@ import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { notifyAdmins } from '@/actions/notifications/notify-admins'; -export const registerEvent = async (eventId: string, options?: { skipRedirect?: boolean }) => { - // Verificar que el evento existe y no está eliminado - const event = await prisma.event.findFirst({ - where: { - id: eventId, - deletedAt: null, - }, - }); - - if (!event) { - throw new Error('Evento no encontrado'); - } - - // Requerir autenticación - solo usuarios autenticados pueden inscribirse +export const registerEvent = async ( + eventId: string, + options?: { skipRedirect?: boolean }, +): Promise<{ success: true; registrationId?: string; status: 'registered' | 'waitlisted' }> => { + // Verificar autenticación antes de la transacción const sessionId = cookies().get('sessionId')?.value; if (!sessionId) { throw new Error('Debes estar autenticado para inscribirte a un evento'); @@ -38,97 +29,120 @@ export const registerEvent = async (eventId: string, options?: { skipRedirect?: const userName = session.user.name; const userEmail = session.user.email; - // Verificar si ya existe una inscripción (activa o cancelada) - const existingRegistration = await prisma.eventRegistration.findFirst({ - where: { - eventId: eventId, - userId: userId, - }, - }); + let resultStatus: 'registered' | 'waitlisted'; + let resultId: string; - // Si existe una inscripción activa, no permitir - if (existingRegistration && existingRegistration.cancelledAt === null) { - throw new Error('Ya estás registrado en este evento'); - } + const result = await prisma.$transaction( + async (tx) => { + // Bloquear el evento para serializar operaciones concurrentes + const events = await tx.$queryRaw<{ id: string }[]>` + SELECT id FROM "Event" WHERE id = ${eventId} AND "deletedAt" IS NULL FOR UPDATE + `; - // Validar cupo disponible (verificar nuevamente antes de crear/actualizar la inscripción) - if (event.capacity !== null) { - const currentRegistrations = await prisma.eventRegistration.count({ - where: { - eventId: eventId, - cancelledAt: null, // Excluir inscripciones canceladas - }, - }); + if (events.length === 0) { + throw new Error('Evento no encontrado'); + } - if (currentRegistrations >= event.capacity) { - throw new Error('El cupo del evento está completo. No se pueden aceptar más inscripciones.'); - } - } + const event = await tx.event.findUnique({ where: { id: eventId } }); + if (!event) throw new Error('Evento no encontrado'); - let registrationId: string; - try { - // Si existe una inscripción cancelada, reactivarla - if (existingRegistration && existingRegistration.cancelledAt !== null) { - await prisma.eventRegistration.update({ - where: { id: existingRegistration.id }, - data: { - cancelledAt: null, // Reactivar la inscripción - }, + // Verificar si ya tiene una inscripción activa + const existingRegistration = await tx.eventRegistration.findFirst({ + where: { eventId, userId }, }); - registrationId = existingRegistration.id; - } else { - // Crear nueva inscripción - const newRegistration = await prisma.eventRegistration.create({ - data: { - eventId: eventId, - userId: userId, - }, + + if (existingRegistration && existingRegistration.cancelledAt === null) { + throw new Error('Ya estás registrado en este evento'); + } + + // Verificar si ya está en la lista de espera + const existingWaitlist = await tx.waitlistEntry.findFirst({ + where: { eventId, userId }, }); - registrationId = newRegistration.id; - } - // Notificar a los admins sobre la nueva inscripción + if (existingWaitlist) { + throw new Error('Ya estás en la lista de espera de este evento'); + } + + // Verificar cupo disponible + const currentCount = + event.capacity !== null + ? await tx.eventRegistration.count({ where: { eventId, cancelledAt: null } }) + : 0; + + const capacityAvailable = event.capacity === null || currentCount < event.capacity; + + if (capacityAvailable) { + // Inscribir directamente + let registrationId: string; + if (existingRegistration && existingRegistration.cancelledAt !== null) { + // Reactivar inscripción cancelada + await tx.eventRegistration.update({ + where: { id: existingRegistration.id }, + data: { cancelledAt: null }, + }); + registrationId = existingRegistration.id; + } else { + const newRegistration = await tx.eventRegistration.create({ + data: { eventId, userId }, + }); + registrationId = newRegistration.id; + } + return { status: 'registered' as const, id: registrationId, eventName: event.name }; + } else { + // Agregar a la lista de espera + const newEntry = await tx.waitlistEntry.create({ + data: { eventId, userId }, + }); + return { status: 'waitlisted' as const, id: newEntry.id, eventName: event.name }; + } + }, + { timeout: 10000 }, + ); + + resultStatus = result.status; + resultId = result.id; + + // Notificar a los admins (fuera de la transacción) + if (resultStatus === 'registered') { await notifyAdmins({ type: 'event_registration_created', title: 'Nueva inscripción a evento', - message: `${userName} se ha inscrito al evento "${event.name}"`, + message: `${userName} se ha inscrito al evento "${result.eventName}"`, metadata: { - eventId: eventId, - eventName: event.name, - registrationId: registrationId, - userName: userName, - userEmail: userEmail, + eventId, + eventName: result.eventName, + registrationId: resultId, + userName, + userEmail, + }, + }); + } else { + await notifyAdmins({ + type: 'event_waitlist_joined', + title: 'Nueva entrada en lista de espera', + message: `${userName} se ha unido a la lista de espera del evento "${result.eventName}"`, + metadata: { + eventId, + eventName: result.eventName, + waitlistEntryId: resultId, + userName, + userEmail, }, }); - } catch (error: any) { - // Manejar error de constraint único de Prisma (caso de condición de carrera) - if (error.code === 'P2002' && error.meta?.target?.includes('userId')) { - // Verificar nuevamente si existe una inscripción activa - const duplicateCheck = await prisma.eventRegistration.findFirst({ - where: { - eventId: eventId, - userId: userId, - cancelledAt: null, - }, - }); - - if (duplicateCheck) { - throw new Error('Ya estás registrado en este evento'); - } - // Si no hay inscripción activa, podría ser un error de timing, reintentar - throw new Error( - 'Ocurrió un error al procesar la inscripción. Por favor, intenta nuevamente.', - ); - } - throw error; } revalidatePath(`/eventos/${eventId}`); - // Si skipRedirect es true, no redirigir (útil para inscripción automática desde el botón) if (options?.skipRedirect) { - return { success: true, registrationId }; + return { + success: true, + registrationId: resultStatus === 'registered' ? resultId : undefined, + status: resultStatus, + }; } - redirect(`/eventos/${eventId}?registered=true`); + redirect( + `/eventos/${eventId}?${resultStatus === 'registered' ? 'registered=true' : 'waitlisted=true'}`, + ); }; diff --git a/src/app/(platform)/eventos/[id]/inscripciones/page.tsx b/src/app/(platform)/eventos/[id]/inscripciones/page.tsx index 486873e1..a1d0fdfc 100644 --- a/src/app/(platform)/eventos/[id]/inscripciones/page.tsx +++ b/src/app/(platform)/eventos/[id]/inscripciones/page.tsx @@ -11,7 +11,7 @@ import { import { Separator } from '@/components/ui/separator'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { Heading2 } from '@/components/ui/heading-2'; -import { ArrowLeft, Users } from 'lucide-react'; +import { ArrowLeft, Users, Clock } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import prisma from '@/lib/prisma'; @@ -82,6 +82,16 @@ const EventRegistrationsPage = async ({ params }: { params: { id: string } }) => const activeRegistrations = registrations.filter((r) => r.cancelledAt === null); const cancelledRegistrations = registrations.filter((r) => r.cancelledAt !== null); + // Obtener lista de espera (solo si el evento tiene capacidad definida) + const waitlistEntries = + event.capacity !== null + ? await prisma.waitlistEntry.findMany({ + where: { eventId: id }, + include: { user: true }, + orderBy: { createdAt: 'asc' }, + }) + : []; + return ( <>
@@ -219,6 +229,50 @@ const EventRegistrationsPage = async ({ params }: { params: { id: string } }) => )} + {/* Lista de espera (solo si el evento tiene capacidad definida) */} + {event.capacity !== null && ( + + + + + Lista de espera ({waitlistEntries.length}{' '} + {waitlistEntries.length === 1 ? 'persona' : 'personas'}) + + + + {waitlistEntries.length === 0 ? ( +

+ No hay personas en la lista de espera. +

+ ) : ( +
+ + + + Posición + Nombre + Email + Fecha de ingreso + + + + {waitlistEntries.map((entry, index) => ( + + #{index + 1} + {entry.user.name} + {entry.user.email} + + {formatDate(entry.createdAt)} + + + ))} + +
+
+ )} +
+
+ )} diff --git a/src/app/(platform)/eventos/[id]/page.tsx b/src/app/(platform)/eventos/[id]/page.tsx index 788c9614..55696e8b 100644 --- a/src/app/(platform)/eventos/[id]/page.tsx +++ b/src/app/(platform)/eventos/[id]/page.tsx @@ -186,6 +186,28 @@ const EventDetailPage: React.FC<{ params: { id: string } }> = async ({ params }) }; } + // Verificar si el usuario está en la lista de espera + let isOnWaitlist = false; + let waitlistPosition: number | null = null; + let waitlistCount = 0; + if (event.capacity !== null) { + if (userId) { + const waitlistEntry = await prisma.waitlistEntry.findFirst({ + where: { eventId: id, userId }, + }); + if (waitlistEntry) { + isOnWaitlist = true; + const entriesAhead = await prisma.waitlistEntry.count({ + where: { eventId: id, createdAt: { lt: waitlistEntry.createdAt } }, + }); + waitlistPosition = entriesAhead + 1; + } + } + waitlistCount = await prisma.waitlistEntry.count({ + where: { eventId: id }, + }); + } + // Obtener inscripciones si el usuario es admin let registrations: Array<{ id: string; @@ -349,6 +371,9 @@ const EventDetailPage: React.FC<{ params: { id: string } }> = async ({ params }) registrationId={registrationId} capacityAvailable={capacityInfo?.available ?? true} capacityInfo={capacityInfo} + isOnWaitlist={isOnWaitlist} + waitlistPosition={waitlistPosition} + waitlistCount={waitlistCount} /> diff --git a/src/components/events/cancel-waitlist-button.tsx b/src/components/events/cancel-waitlist-button.tsx new file mode 100644 index 00000000..3bfcc0be --- /dev/null +++ b/src/components/events/cancel-waitlist-button.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cancelWaitlist } from '@/actions/events/cancel-waitlist'; +import { toast } from 'sonner'; +import { X } from 'lucide-react'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +type CancelWaitlistButtonProps = { + eventId: string; + onCancel?: () => void; +}; + +export function CancelWaitlistButton({ eventId, onCancel }: CancelWaitlistButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const handleCancel = async () => { + setIsLoading(true); + try { + await toast.promise(cancelWaitlist({ eventId }), { + loading: 'Saliendo de la lista de espera...', + success: 'Saliste de la lista de espera exitosamente', + error: (error) => { + console.error('Error al salir de la lista de espera', error); + return error.message || 'Ocurrió un error al salir de la lista de espera'; + }, + }); + if (onCancel) { + onCancel(); + } + router.refresh(); + } catch (error) { + // El error ya se maneja en toast.promise + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/events/event-detail-client.tsx b/src/components/events/event-detail-client.tsx index 4a453a16..c91067df 100644 --- a/src/components/events/event-detail-client.tsx +++ b/src/components/events/event-detail-client.tsx @@ -4,8 +4,10 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { registerEvent } from '@/actions/events/register-event'; import { RegistrationSuccessDialog } from './registration-success-dialog'; +import { WaitlistSuccessDialog } from './waitlist-success-dialog'; import { RegisterEventButton } from './register-event-button'; import { CancelRegistrationButton } from './cancel-registration-button'; +import { CancelWaitlistButton } from './cancel-waitlist-button'; import { toast } from 'sonner'; type Props = { @@ -20,6 +22,9 @@ type Props = { capacity: number; available: boolean; } | null; + isOnWaitlist: boolean; + waitlistPosition: number | null; + waitlistCount: number; }; export function EventDetailClient({ @@ -30,51 +35,59 @@ export function EventDetailClient({ registrationId, capacityAvailable, capacityInfo, + isOnWaitlist: initialIsOnWaitlist, + waitlistPosition, + waitlistCount, }: Props) { const searchParams = useSearchParams(); const router = useRouter(); - const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [showRegistrationDialog, setShowRegistrationDialog] = useState(false); + const [showWaitlistDialog, setShowWaitlistDialog] = useState(false); const [hasAutoRegistered, setHasAutoRegistered] = useState(false); const [isAutoRegistering, setIsAutoRegistering] = useState(false); - // Estado local para saber si ya se registró en esta sesión const [justRegisteredLocally, setJustRegisteredLocally] = useState(false); + const [justWaitlistedLocally, setJustWaitlistedLocally] = useState(false); const autoRegister = searchParams.get('autoRegister') === 'true'; const justRegistered = searchParams.get('registered') === 'true'; + const justWaitlisted = searchParams.get('waitlisted') === 'true'; - // El usuario está registrado si viene del server O si se registró localmente const isRegistered = initialIsRegistered || justRegisteredLocally; + const isOnWaitlist = initialIsOnWaitlist || justWaitlistedLocally; - // Mostrar dialog si viene con registered=true + // Mostrar dialog si viene con registered=true o waitlisted=true useEffect(() => { if (justRegistered) { - setShowSuccessDialog(true); + setShowRegistrationDialog(true); setJustRegisteredLocally(true); - // Limpiar URL sin recargar router.replace(`/eventos/${eventId}`, { scroll: false }); } - }, [justRegistered, eventId, router]); + if (justWaitlisted) { + setShowWaitlistDialog(true); + setJustWaitlistedLocally(true); + router.replace(`/eventos/${eventId}`, { scroll: false }); + } + }, [justRegistered, justWaitlisted, eventId, router]); // Auto-registrar si viene de login/registro useEffect(() => { - if ( - autoRegister && - isAuthenticated && - !isRegistered && - !hasAutoRegistered && - capacityAvailable - ) { + if (autoRegister && isAuthenticated && !isRegistered && !isOnWaitlist && !hasAutoRegistered) { const performAutoRegister = async () => { setHasAutoRegistered(true); setIsAutoRegistering(true); try { - await registerEvent(eventId, { skipRedirect: true }); + const result = await registerEvent(eventId, { skipRedirect: true }); - // Limpiar URL y mostrar dialog router.replace(`/eventos/${eventId}`, { scroll: false }); - setJustRegisteredLocally(true); - setShowSuccessDialog(true); + + if (result.status === 'waitlisted') { + setJustWaitlistedLocally(true); + setShowWaitlistDialog(true); + } else { + setJustRegisteredLocally(true); + setShowRegistrationDialog(true); + } } catch (error: any) { toast.error(error.message || 'Error al inscribirse automáticamente'); router.replace(`/eventos/${eventId}`, { scroll: false }); @@ -85,36 +98,35 @@ export function EventDetailClient({ performAutoRegister(); } else if (autoRegister && !isAuthenticated) { - // Limpiar URL si no está autenticado router.replace(`/eventos/${eventId}`, { scroll: false }); } - }, [ - autoRegister, - isAuthenticated, - isRegistered, - hasAutoRegistered, - eventId, - capacityAvailable, - router, - ]); - - const handleRegistrationSuccess = () => { - setJustRegisteredLocally(true); - setShowSuccessDialog(true); + }, [autoRegister, isAuthenticated, isRegistered, isOnWaitlist, hasAutoRegistered, eventId, router]); + + const handleRegistrationSuccess = (status: 'registered' | 'waitlisted') => { + if (status === 'waitlisted') { + setJustWaitlistedLocally(true); + setShowWaitlistDialog(true); + } else { + setJustRegisteredLocally(true); + setShowRegistrationDialog(true); + } }; const handleDialogClose = () => { - setShowSuccessDialog(false); - // Hacer refresh para actualizar la UI del servidor + setShowRegistrationDialog(false); + setShowWaitlistDialog(false); router.refresh(); }; const handleCancellation = () => { - // Resetear el estado local para mostrar el botón de inscripción setJustRegisteredLocally(false); }; - // Renderizar según el estado + const handleWaitlistCancellation = () => { + setJustWaitlistedLocally(false); + }; + + // Estado 1: Ya inscrito if (isRegistered) { return ( <> @@ -131,7 +143,7 @@ export function EventDetailClient({ @@ -139,17 +151,67 @@ export function EventDetailClient({ ); } + // Estado 2: En la lista de espera + if (isOnWaitlist) { + return ( + <> +
+
+

Estás en la lista de espera

+ {waitlistPosition !== null && ( +

+ Eres #{waitlistPosition} en la lista de espera +

+ )} +

+ Te inscribiremos automáticamente cuando se libere un lugar +

+
+ +
+ + + + ); + } + + // Estado 3: Cupo completo, puede unirse a la lista de espera if (capacityInfo && !capacityInfo.available) { return ( -
-

Cupo completo

-

- Ya no quedan lugares disponibles. -

-
+ <> +
+
+

Cupo completo

+ {waitlistCount > 0 && ( +

+ {waitlistCount} {waitlistCount === 1 ? 'persona' : 'personas'} en lista de espera +

+ )} +
+ +
+ + + ); } + // Estado 4: Puede inscribirse return ( <>
@@ -157,6 +219,7 @@ export function EventDetailClient({ eventId={eventId} isAuthenticated={isAuthenticated} capacityAvailable={capacityAvailable} + mode="register" onSuccess={handleRegistrationSuccess} isLoading={isAutoRegistering} /> @@ -168,7 +231,7 @@ export function EventDetailClient({
diff --git a/src/components/events/register-event-button.tsx b/src/components/events/register-event-button.tsx index c03bc579..7f4bb81c 100644 --- a/src/components/events/register-event-button.tsx +++ b/src/components/events/register-event-button.tsx @@ -1,18 +1,18 @@ 'use client'; import { Button } from '@/components/ui/button'; -import { UserPlus } from 'lucide-react'; +import { UserPlus, Clock } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; import { registerEvent } from '@/actions/events/register-event'; -import { checkEventCapacity } from '@/actions/events/check-event-capacity'; type RegisterEventButtonProps = { eventId: string; isAuthenticated: boolean; capacityAvailable: boolean; - onSuccess?: () => void; + mode?: 'register' | 'waitlist'; + onSuccess?: (status: 'registered' | 'waitlisted') => void; isLoading?: boolean; }; @@ -20,6 +20,7 @@ export function RegisterEventButton({ eventId, isAuthenticated, capacityAvailable, + mode = 'register', onSuccess, isLoading = false, }: RegisterEventButtonProps) { @@ -33,32 +34,19 @@ export function RegisterEventButton({ return; } - // Si no hay cupo disponible, no hacer nada - if (!capacityAvailable) { - return; - } - setIsSubmitting(true); try { - // Validar cupo antes de inscribir - const capacityCheck = await checkEventCapacity(eventId); - if (!capacityCheck.available) { - toast.error( - capacityCheck.message || - 'El cupo del evento está completo. No se pueden aceptar más inscripciones.', - ); - setIsSubmitting(false); - return; - } - - await registerEvent(eventId, { skipRedirect: true }); + const result = await registerEvent(eventId, { skipRedirect: true }); - // Notificar éxito if (onSuccess) { - onSuccess(); + onSuccess(result.status); } else { - toast.success('¡Te has inscrito exitosamente al evento! 🎉'); + if (result.status === 'waitlisted') { + toast.success('¡Te has unido a la lista de espera!'); + } else { + toast.success('¡Te has inscrito exitosamente al evento!'); + } router.refresh(); } } catch (error: any) { @@ -70,16 +58,27 @@ export function RegisterEventButton({ }; const buttonIsLoading = isSubmitting || isLoading; + const isWaitlist = mode === 'waitlist'; return ( ); } diff --git a/src/components/events/waitlist-promotion-email.tsx b/src/components/events/waitlist-promotion-email.tsx new file mode 100644 index 00000000..48c1fb10 --- /dev/null +++ b/src/components/events/waitlist-promotion-email.tsx @@ -0,0 +1,125 @@ +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://programaconnosotros.com'; + +export const WaitlistPromotionEmail = ({ + userName, + eventName, + eventDate, + eventId, +}: { + userName: string; + eventName: string; + eventDate: string; + eventId: string; +}) => ( +
+ {/* Encabezado */} +
+

+ ¡Conseguiste un lugar! +

+
+ + {/* Contenido principal */} +
+

+ ¡Hola {userName}! +

+ +

+ Te informamos que se ha liberado un lugar en el evento y has sido inscrito + automáticamente desde la lista de espera. +

+ +
+

+ {eventName} +

+

{eventDate}

+
+ +

+ Tu inscripción está confirmada. Podés ver los detalles del evento en el siguiente + enlace: +

+ + + +

+ ¡Te esperamos! +

+
+ + {/* Pie de página */} +
+

Este es un mensaje automático. Por favor, no respondas a este correo.

+

+ © {new Date().getFullYear()} programaConNosotros: la comunidad que necesitas para + llevar tu carrera al siguiente nivel. +

+
+
+); diff --git a/src/components/events/waitlist-success-dialog.tsx b/src/components/events/waitlist-success-dialog.tsx new file mode 100644 index 00000000..90f9cec8 --- /dev/null +++ b/src/components/events/waitlist-success-dialog.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Clock } from 'lucide-react'; + +type Props = { + open: boolean; + onClose: () => void; + eventName: string; +}; + +export function WaitlistSuccessDialog({ open, onClose, eventName }: Props) { + return ( + + + +
+ +
+ ¡Te uniste a la lista de espera! + + Estás en la lista de espera de {eventName}. Te inscribiremos + automáticamente cuando se libere un lugar y te avisaremos por email. + +
+
+ +
+
+
+ ); +}