From 6df3baf0bec9a1fa44690c29ad9633cb23a1d52e Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 31 Jan 2026 22:32:54 +0000 Subject: [PATCH] Update site changes --- .../migrate-users-nullable-password.sql | 39 ---- backend/src/db/migrate.ts | 20 ++ backend/src/db/schema.ts | 4 + backend/src/lib/email.ts | 68 +++++++ backend/src/lib/emailTemplates.ts | 177 ++++++++++++++++-- backend/src/routes/events.ts | 75 +++++++- backend/src/routes/payments.ts | 18 +- frontend/.env.example | 1 + frontend/public/images/favicon.png | Bin 0 -> 1333 bytes frontend/public/images/favicon_icon.png | Bin 0 -> 2139 bytes .../src/app/(public)/book/[eventId]/page.tsx | 155 +++++++++++---- .../app/(public)/booking/[ticketId]/page.tsx | 28 +++ frontend/src/app/(public)/community/page.tsx | 31 ++- .../app/(public)/components/HeroSection.tsx | 10 +- .../events/[id]/EventDetailClient.tsx | 22 ++- frontend/src/app/admin/events/page.tsx | 59 ++++++ frontend/src/app/globals.css | 33 +++- frontend/src/app/layout.tsx | 22 +-- frontend/src/app/linktree/page.tsx | 30 +++ frontend/src/context/AuthContext.tsx | 38 +++- frontend/src/i18n/locales/en.json | 9 + frontend/src/i18n/locales/es.json | 9 + frontend/src/lib/api.ts | 2 + frontend/src/lib/socialLinks.tsx | 46 ++++- frontend/tailwind.config.js | 5 +- 25 files changed, 764 insertions(+), 137 deletions(-) delete mode 100644 backend/scripts/migrate-users-nullable-password.sql create mode 100644 frontend/public/images/favicon.png create mode 100644 frontend/public/images/favicon_icon.png diff --git a/backend/scripts/migrate-users-nullable-password.sql b/backend/scripts/migrate-users-nullable-password.sql deleted file mode 100644 index d7d2525..0000000 --- a/backend/scripts/migrate-users-nullable-password.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Migration: Make password column nullable for Google OAuth users --- Run this on your production SQLite database: --- sqlite3 /path/to/spanglish.db < migrate-users-nullable-password.sql - --- SQLite doesn't support ALTER COLUMN, so we need to recreate the table - --- Step 1: Create new table with correct schema (password is nullable) -CREATE TABLE users_new ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - password TEXT, -- Now nullable for Google OAuth users - name TEXT NOT NULL, - phone TEXT, - role TEXT NOT NULL DEFAULT 'user', - language_preference TEXT, - is_claimed INTEGER NOT NULL DEFAULT 1, - google_id TEXT, - ruc_number TEXT, - account_status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - --- Step 2: Copy all existing data -INSERT INTO users_new (id, email, password, name, phone, role, language_preference, is_claimed, google_id, ruc_number, account_status, created_at, updated_at) -SELECT id, email, password, name, phone, role, language_preference, is_claimed, google_id, ruc_number, account_status, created_at, updated_at -FROM users; - --- Step 3: Drop old table -DROP TABLE users; - --- Step 4: Rename new table -ALTER TABLE users_new RENAME TO users; - --- Step 5: Recreate indexes -CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users(email); -CREATE INDEX IF NOT EXISTS users_google_id_idx ON users(google_id); - --- Done! Google OAuth users can now be created without passwords. diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index f1f5015..7c17078 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -83,11 +83,21 @@ async function migrate() { capacity INTEGER NOT NULL DEFAULT 50, status TEXT NOT NULL DEFAULT 'draft', banner_url TEXT, + external_booking_enabled INTEGER NOT NULL DEFAULT 0, + external_booking_url TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); + // Add external booking columns to events if they don't exist (for existing databases) + try { + await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_enabled INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`); + } catch (e) { /* column may already exist */ } + await (db as any).run(sql` CREATE TABLE IF NOT EXISTS tickets ( id TEXT PRIMARY KEY, @@ -414,11 +424,21 @@ async function migrate() { capacity INTEGER NOT NULL DEFAULT 50, status VARCHAR(20) NOT NULL DEFAULT 'draft', banner_url VARCHAR(500), + external_booking_enabled INTEGER NOT NULL DEFAULT 0, + external_booking_url VARCHAR(500), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); + // Add external booking columns to events if they don't exist (for existing databases) + try { + await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_enabled INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS tickets ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 3b8f9d7..075c008 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -75,6 +75,8 @@ export const sqliteEvents = sqliteTable('events', { capacity: integer('capacity').notNull().default(50), status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), bannerUrl: text('banner_url'), + externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), + externalBookingUrl: text('external_booking_url'), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }); @@ -315,6 +317,8 @@ export const pgEvents = pgTable('events', { capacity: pgInteger('capacity').notNull().default(50), status: varchar('status', { length: 20 }).notNull().default('draft'), bannerUrl: varchar('banner_url', { length: 500 }), + externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0), + externalBookingUrl: varchar('external_booking_url', { length: 500 }), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 9cd86e0..0ad4d95 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -786,6 +786,74 @@ export const emailService = { }); }, + /** + * Send payment rejection email + * This email is sent when admin rejects a TPago or Bank Transfer payment + */ + async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> { + // Get payment + const payment = await (db as any) + .select() + .from(payments) + .where(eq((payments as any).id, paymentId)) + .get(); + + if (!payment) { + return { success: false, error: 'Payment not found' }; + } + + // Get ticket + const ticket = await (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + .get(); + + if (!ticket) { + return { success: false, error: 'Ticket not found' }; + } + + // Get event + const event = await (db as any) + .select() + .from(events) + .where(eq((events as any).id, ticket.eventId)) + .get(); + + if (!event) { + return { success: false, error: 'Event not found' }; + } + + const locale = ticket.preferredLanguage || 'en'; + const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; + const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + // Generate a new booking URL for the event + const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; + const newBookingUrl = `${frontendUrl}/book/${event.id}`; + + console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`); + + return this.sendTemplateEmail({ + templateSlug: 'payment-rejected', + to: ticket.attendeeEmail, + toName: attendeeFullName, + locale, + eventId: event.id, + variables: { + attendeeName: attendeeFullName, + attendeeEmail: ticket.attendeeEmail, + ticketId: ticket.id, + eventTitle, + eventDate: this.formatDate(event.startDatetime, locale), + eventTime: this.formatTime(event.startDatetime, locale), + eventLocation: event.location, + eventLocationUrl: event.locationUrl || '', + newBookingUrl, + }, + }); + }, + /** * Send custom email to event attendees */ diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts index 17ccf90..f0779a0 100644 --- a/backend/src/lib/emailTemplates.ts +++ b/backend/src/lib/emailTemplates.ts @@ -673,6 +673,13 @@ El Equipo de Spanglish`, If the button doesn't work: {{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 click the button below to notify us. @@ -685,8 +692,6 @@ El Equipo de Spanglish`, Or use this link: {{bookingUrl}}

-

Your spot will be confirmed once we verify the payment.

-

If you have any questions, just reply to this email.

See you soon,
Spanglish

`, @@ -711,6 +716,13 @@ El Equipo de Spanglish`, Si el botón no funciona: {{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 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}}
+
+ 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 click the button below to notify us. @@ -836,8 +859,6 @@ Spanglish`, Or use this link: {{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,
Spanglish

`, @@ -872,6 +893,13 @@ Spanglish`,
Referencia: {{paymentReference}}
+
+ 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 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 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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}

+ {/* Manual verification notice */} +
+
+
+ + + +
+
+

+ {locale === 'es' ? 'Verificación manual' : 'Manual verification'} +

+

+ {locale === 'es' + ? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.' + : 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'} +

+
+
+
+ + {/* 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.'} +

+ {/* I Have Paid Button */}
-
-
-

{method.label}

- {method.badge && ( - - {method.badge} - - )} + ? 'border-primary-yellow bg-primary-yellow/10' + : 'border-secondary-light-gray hover:border-gray-300' + }`} + > +
+ +
+
+
+

{method.label}

+ {method.badge && ( + + {method.badge} + + )} +
+

{method.description}

+
+ {formData.paymentMethod === method.id && ( + + )} + + ))} + + {/* Manual payment instructions - shown when TPago or Bank Transfer is selected */} + {(formData.paymentMethod === 'tpago' || formData.paymentMethod === 'bank_transfer') && ( +
+
+
+ + + +
+
+

+ {locale === 'es' ? 'Proceso de pago manual' : 'Manual payment process'} +

+
    +
  1. + {locale === 'es' + ? 'Por favor completa el pago primero.' + : 'Please complete the payment first.'} +
  2. +
  3. + {locale === 'es' + ? 'Después de pagar, haz clic en "Ya pagué" para notificarnos.' + : 'After you have paid, click "I have paid" to notify us.'} +
  4. +
  5. + {locale === 'es' + ? 'Nuestro equipo verificará el pago manualmente.' + : 'Our team will manually verify the payment.'} +
  6. +
  7. + {locale === 'es' + ? 'Una vez aprobado, recibirás un email confirmando tu reserva.' + : 'Once approved, you will receive an email confirming your booking.'} +
  8. +
+
-

{method.description}

- {formData.paymentMethod === method.id && ( - - )} - - )) + )} + )}
diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx index bf3abe8..b25bfcd 100644 --- a/frontend/src/app/(public)/booking/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -457,6 +457,34 @@ export default function BookingPaymentPage() {

{ticket.qrCode}

+ {/* Manual verification notice */} +
+
+
+ + + +
+
+

+ {locale === 'es' ? 'Verificación manual' : 'Manual verification'} +

+

+ {locale === 'es' + ? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.' + : 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'} +

+
+
+
+ + {/* 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.'} +

+ {/* I Have Paid Button */} @@ -103,6 +105,29 @@ export default function CommunityPage() { )} + + {/* TikTok Card */} + {tiktokUrl && ( + +
+ + + +
+

{t('community.tiktok.title')}

+

{t('community.tiktok.description')}

+ + + +
+ )} {/* Guidelines */} diff --git a/frontend/src/app/(public)/components/HeroSection.tsx b/frontend/src/app/(public)/components/HeroSection.tsx index 362c4b6..cabbfef 100644 --- a/frontend/src/app/(public)/components/HeroSection.tsx +++ b/frontend/src/app/(public)/components/HeroSection.tsx @@ -4,10 +4,6 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import Button from '@/components/ui/Button'; -import { - ChatBubbleLeftRightIcon, - UserGroupIcon -} from '@heroicons/react/24/outline'; export default function HeroSection() { const { t } = useLanguage(); @@ -51,8 +47,6 @@ export default function HeroSection() { priority fetchPriority="high" /> -
-
-
+
Language exchange group practicing English and Spanish -
-
diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index 8bb41a8..de78a53 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -186,11 +186,23 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail {canBook ? ( - - - + event.externalBookingEnabled && event.externalBookingUrl ? ( + + + + ) : ( + + + + ) ) : ( + + + {formData.externalBookingEnabled && ( +
+ setFormData({ ...formData, externalBookingUrl: e.target.value })} + placeholder="https://example.com/book" + required + /> +

Must be a valid HTTPS URL

+
+ )} + + {/* Image Upload / Media Picker */} - + + diff --git a/frontend/src/app/linktree/page.tsx b/frontend/src/app/linktree/page.tsx index 7fa7917..5a53921 100644 --- a/frontend/src/app/linktree/page.tsx +++ b/frontend/src/app/linktree/page.tsx @@ -19,6 +19,7 @@ export default function LinktreePage() { const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP; const instagramHandle = process.env.NEXT_PUBLIC_INSTAGRAM; const telegramHandle = process.env.NEXT_PUBLIC_TELEGRAM; + const tiktokHandle = process.env.NEXT_PUBLIC_TIKTOK; useEffect(() => { eventsApi.getNextUpcoming() @@ -51,6 +52,10 @@ export default function LinktreePage() { ? (telegramHandle.startsWith('http') ? telegramHandle : `https://t.me/${telegramHandle.replace('@', '')}`) : null; + const tiktokUrl = tiktokHandle + ? (tiktokHandle.startsWith('http') ? tiktokHandle : `https://www.tiktok.com/@${tiktokHandle.replace('@', '')}`) + : null; + return (
@@ -195,6 +200,31 @@ export default function LinktreePage() { )} + + {/* TikTok */} + {tiktokUrl && ( + +
+ + + +
+
+

+ {t('linktree.tiktok.title')} +

+

{t('linktree.tiktok.subtitle')}

+
+ + + +
+ )}
{/* Website Link */} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 45620a8..6b790ac 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -28,6 +28,7 @@ interface AuthContextType { logout: () => void; updateUser: (user: User) => void; setAuthData: (data: { user: User; token: string }) => void; + refreshUser: () => Promise; } interface RegisterData { @@ -48,6 +49,35 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [token, setToken] = useState(null); const [isLoading, setIsLoading] = useState(true); + const refreshUser = useCallback(async () => { + const currentToken = localStorage.getItem(TOKEN_KEY); + if (!currentToken) return; + + try { + const res = await fetch(`${API_BASE}/api/auth/me`, { + headers: { + 'Authorization': `Bearer ${currentToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (res.ok) { + const data = await res.json(); + setUser(data.user); + localStorage.setItem(USER_KEY, JSON.stringify(data.user)); + } else if (res.status === 401) { + // Token is invalid, clear auth state + setToken(null); + setUser(null); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + } + } catch (error) { + // Network error, keep using cached data + console.error('Failed to refresh user data:', error); + } + }, []); + useEffect(() => { // Load auth state from localStorage const savedToken = localStorage.getItem(TOKEN_KEY); @@ -56,9 +86,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (savedToken && savedUser) { setToken(savedToken); setUser(JSON.parse(savedUser)); + // Refresh user data from server to get latest role/permissions + refreshUser().finally(() => setIsLoading(false)); + } else { + setIsLoading(false); } - setIsLoading(false); - }, []); + }, [refreshUser]); const setAuthData = useCallback((data: { user: User; token: string }) => { setToken(data.token); @@ -159,6 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { logout, updateUser, setAuthData, + refreshUser, }} > {children} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 11bbc2a..acb1813 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -164,6 +164,11 @@ "description": "Join our Telegram channel for news and announcements", "button": "Join Telegram" }, + "tiktok": { + "title": "TikTok", + "description": "Watch our videos and follow us for fun content", + "button": "Follow Us" + }, "guidelines": { "title": "Community Guidelines", "items": [ @@ -316,6 +321,10 @@ "instagram": { "title": "Instagram", "subtitle": "Photos & stories" + }, + "tiktok": { + "title": "TikTok", + "subtitle": "Videos & fun content" } } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 2986b6e..24ef7e0 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -164,6 +164,11 @@ "description": "Únete a nuestro canal de Telegram para noticias y anuncios", "button": "Unirse a Telegram" }, + "tiktok": { + "title": "TikTok", + "description": "Mira nuestros videos y síguenos para contenido divertido", + "button": "Seguirnos" + }, "guidelines": { "title": "Reglas de la Comunidad", "items": [ @@ -316,6 +321,10 @@ "instagram": { "title": "Instagram", "subtitle": "Fotos e historias" + }, + "tiktok": { + "title": "TikTok", + "subtitle": "Videos y contenido divertido" } } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dd111a7..41801ed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -422,6 +422,8 @@ export interface Event { capacity: number; status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; bannerUrl?: string; + externalBookingEnabled?: boolean; + externalBookingUrl?: string; bookedCount?: number; availableSeats?: number; createdAt: string; diff --git a/frontend/src/lib/socialLinks.tsx b/frontend/src/lib/socialLinks.tsx index 1fb6633..c36b4ef 100644 --- a/frontend/src/lib/socialLinks.tsx +++ b/frontend/src/lib/socialLinks.tsx @@ -6,10 +6,11 @@ export interface SocialLinks { instagram?: string; email?: string; telegram?: string; + tiktok?: string; } export interface SocialLink { - type: 'whatsapp' | 'instagram' | 'email' | 'telegram'; + type: 'whatsapp' | 'instagram' | 'email' | 'telegram' | 'tiktok'; url: string; label: string; handle?: string; @@ -21,6 +22,7 @@ export const socialConfig: SocialLinks = { instagram: process.env.NEXT_PUBLIC_INSTAGRAM || undefined, email: process.env.NEXT_PUBLIC_EMAIL || undefined, telegram: process.env.NEXT_PUBLIC_TELEGRAM || undefined, + tiktok: process.env.NEXT_PUBLIC_TIKTOK || undefined, }; // Generate URLs from handles/values @@ -62,6 +64,17 @@ export function getTelegramUrl(value?: string): string | null { return `https://t.me/${clean}`; } +export function getTikTokUrl(value?: string): string | null { + if (!value) return null; + // If it's already a full URL, return as-is + if (value.startsWith('https://') || value.startsWith('http://')) { + return value; + } + // Otherwise, treat as handle - add @ if not present + const clean = value.startsWith('@') ? value : `@${value}`; + return `https://www.tiktok.com/${clean}`; +} + // Extract display handle from URL or value function extractInstagramHandle(value: string): string { // If it's a URL, extract the username from the path @@ -83,6 +96,20 @@ function extractTelegramHandle(value: string): string { return `@${value.replace('@', '')}`; } +function extractTikTokHandle(value: string): string { + // If it's a URL, extract the username from the path + if (value.startsWith('http')) { + const match = value.match(/tiktok\.com\/(@?[^/?]+)/); + if (match) { + const handle = match[1]; + return handle.startsWith('@') ? handle : `@${handle}`; + } + return '@tiktok'; + } + // Otherwise it's already a handle + return value.startsWith('@') ? value : `@${value}`; +} + // Get all active social links as an array export function getSocialLinks(): SocialLink[] { const links: SocialLink[] = []; @@ -135,6 +162,18 @@ export function getSocialLinks(): SocialLink[] { } } + if (socialConfig.tiktok) { + const url = getTikTokUrl(socialConfig.tiktok); + if (url) { + links.push({ + type: 'tiktok', + url, + label: 'TikTok', + handle: extractTikTokHandle(socialConfig.tiktok), + }); + } + } + return links; } @@ -160,4 +199,9 @@ export const socialIcons = { ), + tiktok: ( + + + + ), }; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index c424aae..811cc61 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -25,8 +25,9 @@ module.exports = { }, }, fontFamily: { - sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], - heading: ['var(--font-poppins)', 'var(--font-inter)', 'system-ui', 'sans-serif'], + // Poppins everywhere + sans: ['var(--font-poppins)', 'system-ui', 'sans-serif'], + heading: ['var(--font-poppins)', 'system-ui', 'sans-serif'], }, borderRadius: { 'btn': '12px',