or click the button below to notify us.
@@ -685,8 +692,6 @@ El Equipo de Spanglish`,
Or use this link:
Your spot will be confirmed once we verify the payment.
If you have any questions, just reply to this email.
`,
@@ -711,6 +716,13 @@ El Equipo de Spanglish`,
Si el botón no funciona:
Después de completar el pago:
Vuelve al sitio web y haz clic en
"Ya pagué" o haz clic en el botón de abajo para notificarnos.
@@ -723,8 +735,6 @@ El Equipo de Spanglish`,
O usa este enlace:
{{bookingUrl}}
-
Tu lugar será confirmado una vez que verifiquemos el pago.
-
Si tienes alguna pregunta, simplemente responde a este email.
¡Nos vemos pronto!
Spanglish
`,
@@ -743,12 +753,15 @@ Please complete your payment using TPago at the link below:
👉 Pay with card: {{tpagoLink}}
+⚠️ IMPORTANT - Manual Verification Process:
+Please make sure you complete the payment before clicking "I have paid".
+The Spanglish team will review the payment manually.
+Your booking is only confirmed after you receive a confirmation email from us.
+
After completing the payment, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}}
-Your spot will be confirmed once we verify the payment.
-
If you have any questions, just reply to this email.
See you soon,
@@ -768,12 +781,15 @@ Por favor completa tu pago usando TPago en el siguiente enlace:
👉 Pagar con tarjeta: {{tpagoLink}}
+⚠️ IMPORTANTE - Proceso de Verificación Manual:
+Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".
+El equipo de Spanglish revisará el pago manualmente.
+Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.
+
Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}}
-Tu lugar será confirmado una vez que verifiquemos el pago.
-
Si tienes alguna pregunta, simplemente responde a este email.
¡Nos vemos pronto!
@@ -824,6 +840,13 @@ Spanglish`,
Reference: {{paymentReference}}
Después de realizar la transferencia:
Vuelve al sitio web y haz clic en
"Ya pagué" o haz clic en el botón de abajo para notificarnos.
@@ -884,8 +912,6 @@ Spanglish`,
O usa este enlace:
{{bookingUrl}}
-
Confirmaremos tu lugar tan pronto como recibamos el pago.
-
Si necesitas ayuda, responde a este email.
¡Nos vemos en el evento!
Spanglish
`,
@@ -907,12 +933,15 @@ Bank Transfer Details:
- Phone: {{bankPhone}}
- Reference: {{paymentReference}}
+⚠️ IMPORTANT - Manual Verification Process:
+Please make sure you complete the payment before clicking "I have paid".
+The Spanglish team will review the payment manually.
+Your booking is only confirmed after you receive a confirmation email from us.
+
After making the transfer, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}}
-We'll confirm your spot as soon as the payment is received.
-
If you need help, reply to this email.
See you at the event,
@@ -935,12 +964,15 @@ Datos de Transferencia:
- Teléfono: {{bankPhone}}
- Referencia: {{paymentReference}}
-Después de realizar la transferencia, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
+⚠️ IMPORTANTE - Proceso de Verificación Manual:
+Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".
+El equipo de Spanglish revisará el pago manualmente.
+Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.
+
+Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}}
-Confirmaremos tu lugar tan pronto como recibamos el pago.
-
Si necesitas ayuda, responde a este email.
¡Nos vemos en el evento!
@@ -960,6 +992,117 @@ Spanglish`,
],
isSystem: true,
},
+ {
+ name: 'Payment Rejected',
+ slug: 'payment-rejected',
+ subject: 'Payment not found for your Spanglish booking',
+ subjectEs: 'No encontramos el pago de tu reserva en Spanglish',
+ bodyHtml: `
+
About Your Booking
+
Hi {{attendeeName}},
+
Thanks for your interest in Spanglish.
+
Unfortunately, we were unable to find or confirm the payment for your booking for:
+
+
+
{{eventTitle}}
+
📅 Date: {{eventDate}}
+
📍 Location: {{eventLocation}}
+
+
+
Because of this, the booking has been cancelled.
+
+
+ Did you already pay?
+ If you believe this was a mistake or if you have already made the payment, please reply to this email and we'll be happy to check it with you.
+
+
+
You're always welcome to make a new booking if spots are still available.
+
+ {{#if newBookingUrl}}
+
+ Make a New Booking
+
+ {{/if}}
+
+
Warm regards,
Spanglish
+ `,
+ bodyHtmlEs: `
+
Sobre Tu Reserva
+
Hola {{attendeeName}},
+
Gracias por tu interés en Spanglish.
+
Lamentablemente, no pudimos encontrar o confirmar el pago de tu reserva para:
+
+
+
{{eventTitle}}
+
📅 Fecha: {{eventDate}}
+
📍 Ubicación: {{eventLocation}}
+
+
+
Por esta razón, la reserva ha sido cancelada.
+
+
+ ¿Ya realizaste el pago?
+ Si crees que esto fue un error o si ya realizaste el pago, por favor responde a este correo y con gusto lo revisaremos contigo.
+
+
+
Siempre eres bienvenido/a a hacer una nueva reserva si aún hay lugares disponibles.
+
+ {{#if newBookingUrl}}
+
+ Hacer una Nueva Reserva
+
+ {{/if}}
+
+
Saludos cordiales,
Spanglish
+ `,
+ bodyText: `About Your Booking
+
+Hi {{attendeeName}},
+
+Thanks for your interest in Spanglish.
+
+Unfortunately, we were unable to find or confirm the payment for your booking for:
+
+{{eventTitle}}
+📅 Date: {{eventDate}}
+📍 Location: {{eventLocation}}
+
+Because of this, the booking has been cancelled.
+
+If you believe this was a mistake or if you have already made the payment, please reply to this email and we'll be happy to check it with you.
+
+You're always welcome to make a new booking if spots are still available.
+
+Warm regards,
+Spanglish`,
+ bodyTextEs: `Sobre Tu Reserva
+
+Hola {{attendeeName}},
+
+Gracias por tu interés en Spanglish.
+
+Lamentablemente, no pudimos encontrar o confirmar el pago de tu reserva para:
+
+{{eventTitle}}
+📅 Fecha: {{eventDate}}
+📍 Ubicación: {{eventLocation}}
+
+Por esta razón, la reserva ha sido cancelada.
+
+Si crees que esto fue un error o si ya realizaste el pago, por favor responde a este correo y con gusto lo revisaremos contigo.
+
+Siempre eres bienvenido/a a hacer una nueva reserva si aún hay lugares disponibles.
+
+Saludos cordiales,
+Spanglish`,
+ description: 'Sent when admin rejects a TPago or Bank Transfer payment',
+ variables: [
+ ...commonVariables,
+ ...bookingVariables,
+ { name: 'newBookingUrl', description: 'URL to make a new booking (optional)', example: 'https://spanglish.com/book/event123' },
+ ],
+ isSystem: true,
+ },
];
// Helper function to replace template variables
diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts
index c6f4b4c..2b549b2 100644
--- a/backend/src/routes/events.ts
+++ b/backend/src/routes/events.ts
@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
-import { db, events, tickets } from '../db/index.js';
+import { db, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
@@ -23,7 +23,7 @@ const validationHook = (result: any, c: any) => {
}
};
-const createEventSchema = z.object({
+const baseEventSchema = z.object({
title: z.string().min(1),
titleEs: z.string().optional().nullable(),
description: z.string().min(1),
@@ -38,9 +38,38 @@ const createEventSchema = z.object({
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')),
+ // External booking support
+ externalBookingEnabled: z.boolean().default(false),
+ externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
});
-const updateEventSchema = createEventSchema.partial();
+const createEventSchema = baseEventSchema.refine(
+ (data) => {
+ // If external booking is enabled, URL must be provided and must start with https://
+ if (data.externalBookingEnabled) {
+ return !!(data.externalBookingUrl && data.externalBookingUrl.startsWith('https://'));
+ }
+ return true;
+ },
+ {
+ message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
+ path: ['externalBookingUrl'],
+ }
+);
+
+const updateEventSchema = baseEventSchema.partial().refine(
+ (data) => {
+ // If external booking is enabled, URL must be provided and must start with https://
+ if (data.externalBookingEnabled) {
+ return !!(data.externalBookingUrl && data.externalBookingUrl.startsWith('https://'));
+ }
+ return true;
+ },
+ {
+ message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
+ path: ['externalBookingUrl'],
+ }
+);
// Get all events (public)
eventsRouter.get('/', async (c) => {
@@ -211,6 +240,44 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Event not found' }, 404);
}
+ // Get all tickets for this event
+ const eventTickets = await (db as any)
+ .select()
+ .from(tickets)
+ .where(eq((tickets as any).eventId, id))
+ .all();
+
+ // Delete invoices and payments for all tickets of this event
+ for (const ticket of eventTickets) {
+ // Get payments for this ticket
+ const ticketPayments = await (db as any)
+ .select()
+ .from(payments)
+ .where(eq((payments as any).ticketId, ticket.id))
+ .all();
+
+ // Delete invoices for each payment
+ for (const payment of ticketPayments) {
+ await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
+ }
+
+ // Delete payments for this ticket
+ await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
+ }
+
+ // Delete all tickets for this event
+ await (db as any).delete(tickets).where(eq((tickets as any).eventId, id));
+
+ // Delete event payment overrides
+ await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id));
+
+ // Set eventId to null on email logs (they reference this event but can exist without it)
+ await (db as any)
+ .update(emailLogs)
+ .set({ eventId: null })
+ .where(eq((emailLogs as any).eventId, id));
+
+ // Finally delete the event
await (db as any).delete(events).where(eq((events as any).id, id));
return c.json({ message: 'Event deleted successfully' });
@@ -257,6 +324,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
capacity: existing.capacity,
status: 'draft',
bannerUrl: existing.bannerUrl,
+ externalBookingEnabled: existing.externalBookingEnabled || false,
+ externalBookingUrl: existing.externalBookingUrl,
createdAt: now,
updatedAt: now,
};
diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts
index 5320453..683bcab 100644
--- a/backend/src/routes/payments.ts
+++ b/backend/src/routes/payments.ts
@@ -311,7 +311,21 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
})
.where(eq((payments as any).id, id));
- // Note: We don't cancel the ticket automatically - admin can do that separately if needed
+ // Cancel the ticket - booking is no longer valid after rejection
+ await (db as any)
+ .update(tickets)
+ .set({
+ status: 'cancelled',
+ updatedAt: now,
+ })
+ .where(eq((tickets as any).id, payment.ticketId));
+
+ // Send rejection email asynchronously (for manual payment methods only)
+ if (['bank_transfer', 'tpago'].includes(payment.provider)) {
+ emailService.sendPaymentRejectionEmail(id).catch(err => {
+ console.error('[Email] Failed to send payment rejection email:', err);
+ });
+ }
const updated = await (db as any)
.select()
@@ -319,7 +333,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
.where(eq((payments as any).id, id))
.get();
- return c.json({ payment: updated, message: 'Payment rejected' });
+ return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
});
// Update admin note
diff --git a/frontend/.env.example b/frontend/.env.example
index ef2402d..1e240b6 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -19,6 +19,7 @@ NEXT_PUBLIC_WHATSAPP=+595991234567
NEXT_PUBLIC_INSTAGRAM=spanglish_py
NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
NEXT_PUBLIC_TELEGRAM=spanglish_py
+NEXT_PUBLIC_TIKTOK=spanglishsocialpy
# Plausible Analytics (optional - leave empty to disable tracking)
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
diff --git a/frontend/public/images/favicon.png b/frontend/public/images/favicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..b0a9b4a2f704b71fb9a33f5111d9d0e542095a0f
GIT binary patch
literal 1333
zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyjKx9jPK-BC>eK@{oCO|{#S9GG
z!XV7ZFl&wk0|S$PfKP}kQ1VAJ(~o9mH1rci5TO8799
rsBLk3iI{=NjL&>(G-KYU}NEG;0)}Fo0&mo
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
Rx6%Lr002ovPDHLkV1ioa@L2!=
literal 0
HcmV?d00001
diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx
index 0e95edd..8566db5 100644
--- a/frontend/src/app/(public)/book/[eventId]/page.tsx
+++ b/frontend/src/app/(public)/book/[eventId]/page.tsx
@@ -160,6 +160,13 @@ export default function BookingPage() {
router.push('/events');
return;
}
+
+ // Redirect to external booking if enabled
+ if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) {
+ window.location.href = eventRes.event.externalBookingUrl;
+ return;
+ }
+
setEvent(eventRes.event);
setPaymentConfig(paymentRes.paymentOptions);
@@ -696,6 +703,34 @@ export default function BookingPage() {
{bookingResult.qrCode}
+
+ {/* Warning before I Have Paid button */}
+
+ {locale === 'es'
+ ? 'Solo haz clic aquí después de haber completado el pago.'
+ : 'Only click this after you have actually completed the payment.'}
+