diff --git a/.gitignore b/.gitignore index 075936e..056e71c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ coverage/ # Misc *.pem + +# Documentation (internal) +about/ diff --git a/backend/.env.example b/backend/.env.example index f88031e..614ac56 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,11 @@ DATABASE_URL=./data/spanglish.db # JWT Secret (change in production!) JWT_SECRET=your-super-secret-key-change-in-production +# Google OAuth (optional - for Google Sign-In) +# Get your Client ID from: https://console.cloud.google.com/apis/credentials +# Note: The same Client ID should be used in frontend/.env +GOOGLE_CLIENT_ID= + # Server Configuration PORT=3001 API_URL=http://localhost:3001 diff --git a/backend/scripts/migrate-users-nullable-password.sql b/backend/scripts/migrate-users-nullable-password.sql new file mode 100644 index 0000000..d7d2525 --- /dev/null +++ b/backend/scripts/migrate-users-nullable-password.sql @@ -0,0 +1,39 @@ +-- 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 d87f28d..f1f5015 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -216,6 +216,11 @@ async function migrate() { ) `); + // Add allow_duplicate_bookings column to payment_options if it doesn't exist + try { + await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + // Event payment overrides table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS event_payment_overrides ( @@ -497,6 +502,11 @@ async function migrate() { ) `); + // Add allow_duplicate_bookings column to payment_options if it doesn't exist + try { + await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS event_payment_overrides ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index b9d824a..3b8f9d7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -135,6 +135,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', { cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true), cashInstructions: text('cash_instructions'), cashInstructionsEs: text('cash_instructions_es'), + // Booking settings + allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false), // Metadata updatedAt: text('updated_at').notNull(), updatedBy: text('updated_by').references(() => sqliteUsers.id), @@ -369,6 +371,7 @@ export const pgPaymentOptions = pgTable('payment_options', { cashEnabled: pgInteger('cash_enabled').notNull().default(1), cashInstructions: pgText('cash_instructions'), cashInstructionsEs: pgText('cash_instructions_es'), + allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0), updatedAt: timestamp('updated_at').notNull(), updatedBy: uuid('updated_by').references(() => pgUsers.id), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 19db9d6..9b2d95a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,13 +26,30 @@ const app = new Hono(); // Middleware app.use('*', logger()); -// CORS: Only enable in development. In production, nginx handles CORS. -if (process.env.NODE_ENV !== 'production') { - app.use('*', cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:3002', +// CORS +// - In production we *typically* rely on nginx to set CORS, but enabling it here +// is a safe fallback (especially for local/proxyless deployments). +// - `FRONTEND_URL` should be set to e.g. https://spanglishcommunity.com in prod. +const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; +const allowedOrigins = new Set([ + frontendUrl, + // Common alias (www) for the same site. + frontendUrl.replace('://www.', '://'), + frontendUrl.includes('://') ? frontendUrl.replace('://', '://www.') : frontendUrl, +]); + +app.use( + '*', + cors({ + origin: (origin) => { + // Non-browser / same-origin requests may omit Origin. + if (!origin) return frontendUrl; + return allowedOrigins.has(origin) ? origin : null; + }, + // We use bearer tokens, but keeping credentials=true matches nginx config. credentials: true, - })); -} + }) +); // OpenAPI specification const openApiSpec = { diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 4ba1dc2..9cd86e0 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1,7 +1,7 @@ // Email service for Spanglish platform // Supports multiple email providers: Resend, SMTP (Nodemailer) -import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js'; +import { db, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js'; import { eq, and } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { getNow } from './utils.js'; @@ -372,17 +372,17 @@ export const emailService = { }, /** - * Seed default templates if they don't exist + * Seed default templates if they don't exist, and update system templates with latest content */ async seedDefaultTemplates(): Promise { console.log('[Email] Checking for default templates...'); for (const template of defaultTemplates) { const existing = await this.getTemplate(template.slug); + const now = getNow(); if (!existing) { console.log(`[Email] Creating template: ${template.name}`); - const now = getNow(); await (db as any).insert(emailTemplates).values({ id: nanoid(), @@ -401,6 +401,24 @@ export const emailService = { createdAt: now, updatedAt: now, }); + } else if (existing.isSystem) { + // Update system templates with latest content from defaults + console.log(`[Email] Updating system template: ${template.name}`); + + await (db as any) + .update(emailTemplates) + .set({ + subject: template.subject, + subjectEs: template.subjectEs, + bodyHtml: template.bodyHtml, + bodyHtmlEs: template.bodyHtmlEs, + bodyText: template.bodyText, + bodyTextEs: template.bodyTextEs, + description: template.description, + variables: JSON.stringify(template.variables), + updatedAt: now, + }) + .where(eq((emailTemplates as any).slug, template.slug)); } } @@ -615,6 +633,159 @@ export const emailService = { }); }, + /** + * Get merged payment configuration for an event (global + overrides) + */ + async getPaymentConfig(eventId: string): Promise> { + // Get global options + const globalOptions = await (db as any) + .select() + .from(paymentOptions) + .get(); + + // Get event overrides + const overrides = await (db as any) + .select() + .from(eventPaymentOverrides) + .where(eq((eventPaymentOverrides as any).eventId, eventId)) + .get(); + + // Defaults + const defaults = { + tpagoEnabled: false, + tpagoLink: null, + tpagoInstructions: null, + tpagoInstructionsEs: null, + bankTransferEnabled: false, + bankName: null, + bankAccountHolder: null, + bankAccountNumber: null, + bankAlias: null, + bankPhone: null, + bankNotes: null, + bankNotesEs: null, + }; + + const global = globalOptions || defaults; + + // Merge: override values take precedence if they're not null/undefined + return { + tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled, + tpagoLink: overrides?.tpagoLink ?? global.tpagoLink, + tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions, + tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs, + bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled, + bankName: overrides?.bankName ?? global.bankName, + bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder, + bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber, + bankAlias: overrides?.bankAlias ?? global.bankAlias, + bankPhone: overrides?.bankPhone ?? global.bankPhone, + bankNotes: overrides?.bankNotes ?? global.bankNotes, + bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs, + }; + }, + + /** + * Send payment instructions email (for TPago or Bank Transfer) + * This email is sent immediately after user clicks "Continue to Payment" + */ + async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> { + // Get ticket + const ticket = await (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, 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' }; + } + + // Get payment + const payment = await (db as any) + .select() + .from(payments) + .where(eq((payments as any).ticketId, ticketId)) + .get(); + + if (!payment) { + return { success: false, error: 'Payment not found' }; + } + + // Only send for manual payment methods + if (!['bank_transfer', 'tpago'].includes(payment.provider)) { + return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' }; + } + + // Get merged payment config for this event + const paymentConfig = await this.getPaymentConfig(event.id); + + const locale = ticket.preferredLanguage || 'en'; + const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; + const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + // Generate a payment reference using ticket ID + const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`; + + // Generate the booking URL for returning to payment page + const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; + const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`; + + // Determine which template to use + const templateSlug = payment.provider === 'tpago' + ? 'payment-instructions-tpago' + : 'payment-instructions-bank-transfer'; + + // Build variables based on payment method + const variables: Record = { + 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 || '', + paymentAmount: this.formatCurrency(event.price, event.currency), + paymentReference, + bookingUrl, + }; + + // Add payment-method specific variables + if (payment.provider === 'tpago') { + variables.tpagoLink = paymentConfig.tpagoLink || ''; + } else { + // Bank transfer + variables.bankName = paymentConfig.bankName || ''; + variables.bankAccountHolder = paymentConfig.bankAccountHolder || ''; + variables.bankAccountNumber = paymentConfig.bankAccountNumber || ''; + variables.bankAlias = paymentConfig.bankAlias || ''; + variables.bankPhone = paymentConfig.bankPhone || ''; + } + + console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`); + + return this.sendTemplateEmail({ + templateSlug, + to: ticket.attendeeEmail, + toName: attendeeFullName, + locale, + eventId: event.id, + variables, + }); + }, + /** * Send custom email to event attendees */ diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts index c4b39eb..2468477 100644 --- a/backend/src/lib/emailTemplates.ts +++ b/backend/src/lib/emailTemplates.ts @@ -51,6 +51,16 @@ export const paymentVariables: EmailVariable[] = [ { name: 'paymentDate', description: 'Payment date', example: 'January 28, 2026' }, ]; +// Payment instructions variables (for manual payment methods) +export const paymentInstructionsVariables: EmailVariable[] = [ + { name: 'tpagoLink', description: 'TPago payment link', example: 'https://tpago.com.py/...' }, + { name: 'bankName', description: 'Bank name', example: 'Banco Itaú' }, + { name: 'bankAccountHolder', description: 'Account holder name', example: 'Spanglish SRL' }, + { name: 'bankAccountNumber', description: 'Bank account number', example: '1234567890' }, + { name: 'bankAlias', description: 'Bank alias or phone', example: '0981-123-456' }, + { name: 'bankPhone', description: 'Bank phone number', example: '0981-123-456' }, +]; + // Base HTML wrapper for all emails export const baseEmailWrapper = ` @@ -641,6 +651,319 @@ El Equipo de Spanglish`, ], isSystem: true, }, + { + name: 'Payment Instructions - TPago', + slug: 'payment-instructions-tpago', + subject: 'Complete your payment for Spanglish', + subjectEs: 'Completa tu pago para Spanglish', + bodyHtml: ` +

You're Almost In! 🎉

+

Hi {{attendeeName}},

+

To complete your booking for:

+ +
+

{{eventTitle}}

+
📅 Date: {{eventDate}}
+
📍 Location: {{eventLocation}}
+
💰 Amount: {{paymentAmount}}
+
+ +

Please complete your payment using TPago at the link below:

+ +

+ 👉 Pay with Card +

+

+ If the button doesn't work: {{tpagoLink}} +

+ +
+ After completing the payment:
+ Return to the website and click "I have paid" or click the button below to notify us. +
+ +

+ ✓ I Have Paid +

+

+ 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

+ `, + bodyHtmlEs: ` +

¡Ya Casi Estás! 🎉

+

Hola {{attendeeName}},

+

Para completar tu reserva para:

+ +
+

{{eventTitle}}

+
📅 Fecha: {{eventDate}}
+
📍 Ubicación: {{eventLocation}}
+
💰 Monto: {{paymentAmount}}
+
+ +

Por favor completa tu pago usando TPago en el siguiente enlace:

+ +

+ 👉 Pagar con Tarjeta +

+

+ Si el botón no funciona: {{tpagoLink}} +

+ +
+ 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. +
+ +

+ ✓ Ya Pagué +

+

+ 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

+ `, + bodyText: `You're Almost In! + +Hi {{attendeeName}}, + +To complete your booking for: + +{{eventTitle}} +📅 Date: {{eventDate}} +📍 Location: {{eventLocation}} +💰 Amount: {{paymentAmount}} + +Please complete your payment using TPago at the link below: + +👉 Pay with card: {{tpagoLink}} + +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, +Spanglish`, + bodyTextEs: `¡Ya Casi Estás! + +Hola {{attendeeName}}, + +Para completar tu reserva para: + +{{eventTitle}} +📅 Fecha: {{eventDate}} +📍 Ubicación: {{eventLocation}} +💰 Monto: {{paymentAmount}} + +Por favor completa tu pago usando TPago en el siguiente enlace: + +👉 Pagar con tarjeta: {{tpagoLink}} + +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! +Spanglish`, + description: 'Sent when user selects TPago payment and clicks continue to payment', + variables: [ + ...commonVariables, + ...bookingVariables, + { name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' }, + { name: 'tpagoLink', description: 'TPago payment link', example: 'https://tpago.com.py/...' }, + { name: 'bookingUrl', description: 'URL to return to payment page', example: 'https://spanglish.com/booking/abc123?step=payment' }, + ], + isSystem: true, + }, + { + name: 'Payment Instructions - Bank Transfer', + slug: 'payment-instructions-bank-transfer', + subject: 'Bank transfer details for your Spanglish booking', + subjectEs: 'Datos de transferencia para tu reserva en Spanglish', + bodyHtml: ` +

Thanks for Joining Spanglish! 🙂

+

Hi {{attendeeName}},

+

Here are the bank transfer details for your booking:

+ +
+

{{eventTitle}}

+
📅 Date: {{eventDate}}
+
💰 Amount: {{paymentAmount}}
+
+ +
+

🏦 Bank Transfer Details

+ {{#if bankName}} +
Bank: {{bankName}}
+ {{/if}} + {{#if bankAccountHolder}} +
Account Holder: {{bankAccountHolder}}
+ {{/if}} + {{#if bankAccountNumber}} +
Account Number: {{bankAccountNumber}}
+ {{/if}} + {{#if bankAlias}} +
Alias: {{bankAlias}}
+ {{/if}} + {{#if bankPhone}} +
Phone: {{bankPhone}}
+ {{/if}} +
Reference: {{paymentReference}}
+
+ +
+ After making the transfer:
+ Return to the website and click "I have paid" or click the button below to notify us. +
+ +

+ ✓ I Have Paid +

+

+ 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

+ `, + bodyHtmlEs: ` +

¡Gracias por unirte a Spanglish! 🙂

+

Hola {{attendeeName}},

+

Aquí están los datos de transferencia para tu reserva:

+ +
+

{{eventTitle}}

+
📅 Fecha: {{eventDate}}
+
💰 Monto: {{paymentAmount}}
+
+ +
+

🏦 Datos de Transferencia

+ {{#if bankName}} +
Banco: {{bankName}}
+ {{/if}} + {{#if bankAccountHolder}} +
Titular: {{bankAccountHolder}}
+ {{/if}} + {{#if bankAccountNumber}} +
Nro. Cuenta: {{bankAccountNumber}}
+ {{/if}} + {{#if bankAlias}} +
Alias: {{bankAlias}}
+ {{/if}} + {{#if bankPhone}} +
Teléfono: {{bankPhone}}
+ {{/if}} +
Referencia: {{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. +
+ +

+ ✓ Ya Pagué +

+

+ 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

+ `, + bodyText: `Thanks for Joining Spanglish! + +Hi {{attendeeName}}, + +Here are the bank transfer details for your booking: + +{{eventTitle}} +📅 Date: {{eventDate}} +💰 Amount: {{paymentAmount}} + +Bank Transfer Details: +- Bank: {{bankName}} +- Account Holder: {{bankAccountHolder}} +- Account Number: {{bankAccountNumber}} +- Alias: {{bankAlias}} +- Phone: {{bankPhone}} +- Reference: {{paymentReference}} + +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, +Spanglish`, + bodyTextEs: `¡Gracias por unirte a Spanglish! + +Hola {{attendeeName}}, + +Aquí están los datos de transferencia para tu reserva: + +{{eventTitle}} +📅 Fecha: {{eventDate}} +💰 Monto: {{paymentAmount}} + +Datos de Transferencia: +- Banco: {{bankName}} +- Titular: {{bankAccountHolder}} +- Nro. Cuenta: {{bankAccountNumber}} +- Alias: {{bankAlias}} +- 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: + +{{bookingUrl}} + +Confirmaremos tu lugar tan pronto como recibamos el pago. + +Si necesitas ayuda, responde a este email. + +¡Nos vemos en el evento! +Spanglish`, + description: 'Sent when user selects bank transfer payment and clicks continue to payment', + variables: [ + ...commonVariables, + ...bookingVariables, + { name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' }, + { name: 'paymentReference', description: 'Unique payment reference', example: 'SPG-ABC123' }, + { name: 'bankName', description: 'Bank name', example: 'Banco Itaú' }, + { name: 'bankAccountHolder', description: 'Account holder name', example: 'Spanglish SRL' }, + { name: 'bankAccountNumber', description: 'Bank account number', example: '1234567890' }, + { name: 'bankAlias', description: 'Bank alias', example: 'spanglish.py' }, + { name: 'bankPhone', description: 'Bank phone number', example: '0981-123-456' }, + { name: 'bookingUrl', description: 'URL to return to payment page', example: 'https://spanglish.com/booking/abc123?step=payment' }, + ], + isSystem: true, + }, ]; // Helper function to replace template variables diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 7b4160a..96aa995 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -11,7 +11,8 @@ const mediaRouter = new Hono(); const UPLOAD_DIR = './uploads'; const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']; -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const MAX_FILE_SIZE = + (Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB // Ensure upload directory exists async function ensureUploadDir() { @@ -37,7 +38,8 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => { // Validate file size if (file.size > MAX_FILE_SIZE) { - return c.json({ error: 'File too large. Maximum size: 5MB' }, 400); + const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10; + return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400); } await ensureUploadDir(); diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 7e4797a..369ecd2 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -26,6 +26,8 @@ const updatePaymentOptionsSchema = z.object({ cashEnabled: z.boolean().optional(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), + // Booking settings + allowDuplicateBookings: z.boolean().optional(), }); // Schema for event-level overrides @@ -75,6 +77,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { cashEnabled: true, cashInstructions: null, cashInstructionsEs: null, + allowDuplicateBookings: false, }, }); } diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 756bcf2..979567a 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, tickets, events, users, payments } from '../db/index.js'; +import { db, tickets, events, users, payments, paymentOptions } from '../db/index.js'; import { eq, and, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow } from '../lib/utils.js'; @@ -13,9 +13,9 @@ const ticketsRouter = new Hono(); const createTicketSchema = z.object({ eventId: z.string(), firstName: z.string().min(2), - lastName: z.string().min(2), + lastName: z.string().min(2).optional().or(z.literal('')), email: z.string().email(), - phone: z.string().min(6, 'Phone number is required'), + phone: z.string().min(6).optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(), @@ -76,7 +76,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const now = getNow(); - const fullName = `${data.firstName} ${data.lastName}`.trim(); + const fullName = data.lastName && data.lastName.trim() + ? `${data.firstName} ${data.lastName}`.trim() + : data.firstName; if (!user) { const userId = generateId(); @@ -94,20 +96,29 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { await (db as any).insert(users).values(user); } - // Check for duplicate booking - const existingTicket = await (db as any) + // Check for duplicate booking (unless allowDuplicateBookings is enabled) + const globalOptions = await (db as any) .select() - .from(tickets) - .where( - and( - eq((tickets as any).userId, user.id), - eq((tickets as any).eventId, data.eventId) - ) - ) + .from(paymentOptions) .get(); - if (existingTicket && existingTicket.status !== 'cancelled') { - return c.json({ error: 'You have already booked this event' }, 400); + const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false; + + if (!allowDuplicateBookings) { + const existingTicket = await (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).userId, user.id), + eq((tickets as any).eventId, data.eventId) + ) + ) + .get(); + + if (existingTicket && existingTicket.status !== 'cancelled') { + return c.json({ error: 'You have already booked this event' }, 400); + } } // Create ticket @@ -122,9 +133,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { userId: user.id, eventId: data.eventId, attendeeFirstName: data.firstName, - attendeeLastName: data.lastName, + attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeEmail: data.email, - attendeePhone: data.phone, + attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeeRuc: data.ruc || null, preferredLanguage: data.preferredLanguage || null, status: ticketStatus, @@ -151,6 +162,20 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { await (db as any).insert(payments).values(newPayment); + // Send payment instructions email for manual payment methods (TPago, Bank Transfer) + if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) { + // Send asynchronously - don't block the response + emailService.sendPaymentInstructions(ticketId).then(result => { + if (result.success) { + console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`); + } else { + console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending payment instructions email:', err); + }); + } + // If Lightning payment, create LNbits invoice let lnbitsInvoice = null; if (data.paymentMethod === 'lightning' && event.price > 0) { @@ -389,6 +414,23 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => { return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400); } + // Handle idempotency - if already marked as sent or paid, return success with current state + if (payment.status === 'pending_approval') { + return c.json({ + payment, + message: 'Payment was already marked as sent. Waiting for admin approval.', + alreadyProcessed: true, + }); + } + + if (payment.status === 'paid') { + return c.json({ + payment, + message: 'Payment has already been confirmed.', + alreadyProcessed: true, + }); + } + // Only allow if currently pending if (payment.status !== 'pending') { return c.json({ error: 'Payment has already been processed' }, 400); diff --git a/deploy/back-end_nginx.conf b/deploy/back-end_nginx.conf index cb39bb3..589202d 100644 --- a/deploy/back-end_nginx.conf +++ b/deploy/back-end_nginx.conf @@ -24,6 +24,10 @@ server { server_name api.spanglishcommunity.com; + # Upload size limit (avoid nginx 413 on media uploads) + # Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB). + client_max_body_size 20m; + # SSL ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem; @@ -39,34 +43,44 @@ server { access_log /var/log/nginx/spanglish_api_access.log; error_log /var/log/nginx/spanglish_api_error.log; + # CORS Configuration (set once, used everywhere) + set $cors_origin ""; + if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") { + set $cors_origin $http_origin; + } + + # Add CORS headers to all responses (including nginx-generated errors) + add_header 'Access-Control-Allow-Origin' $cors_origin always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + + # Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked") + error_page 413 = @payload_too_large; + location @payload_too_large { + default_type application/json; + return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; + } + location / { limit_req zone=spanglish_api_limit burst=50 nodelay; - # CORS Configuration - set $cors_origin ""; - if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") { - set $cors_origin $http_origin; - } - # Handle preflight OPTIONS requests + # NOTE: add_header inside if{} does NOT inherit server-level headers, + # so we must repeat all CORS headers here. if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' $cors_origin always; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Max-Age' 86400 always; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } - # Add CORS headers to all responses - add_header 'Access-Control-Allow-Origin' $cors_origin always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; - add_header 'Access-Control-Allow-Credentials' 'true' always; - add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; - proxy_pass http://spanglish_backend; proxy_http_version 1.1; @@ -75,6 +89,13 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # Strip CORS headers from backend (nginx handles CORS at server level) + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + proxy_hide_header 'Access-Control-Allow-Headers'; + proxy_hide_header 'Access-Control-Allow-Credentials'; + proxy_hide_header 'Access-Control-Expose-Headers'; + proxy_read_timeout 300s; proxy_connect_timeout 300s; } diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 3a2771c..7b557d0 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -24,6 +24,9 @@ server { server_name spanglishcommunity.com www.spanglishcommunity.com; + # Upload size limit (covers same-origin /api uploads via this vhost) + client_max_body_size 20m; + # SSL ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem; diff --git a/frontend/.env.example b/frontend/.env.example index 64b54b0..bcc17eb 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,11 +1,25 @@ # Frontend port (dev/start) PORT=3002 +# Site URL (for SEO canonical URLs, sitemap, etc.) +NEXT_PUBLIC_SITE_URL=https://spanglish.com.py + # API URL (leave empty for same-origin proxy) NEXT_PUBLIC_API_URL= +# Google OAuth (optional - leave empty to hide Google Sign-In button) +# Get your Client ID from: https://console.cloud.google.com/apis/credentials +# 1. Create a new OAuth 2.0 Client ID (Web application) +# 2. Add authorized JavaScript origins: http://localhost:3002, https://yourdomain.com +# 3. Add authorized redirect URIs: http://localhost:3002, https://yourdomain.com +NEXT_PUBLIC_GOOGLE_CLIENT_ID= + # Social Links (optional - leave empty to hide) NEXT_PUBLIC_WHATSAPP=+595991234567 NEXT_PUBLIC_INSTAGRAM=spanglish_py NEXT_PUBLIC_EMAIL=hola@spanglish.com.py NEXT_PUBLIC_TELEGRAM=spanglish_py + +# Plausible Analytics (optional - leave empty to disable tracking) +NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net +NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..1918abe --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "Spanglish – Language Exchange Events in Asunción", + "short_name": "Spanglish", + "description": "Practice English and Spanish at relaxed social events in Asunción, Paraguay. Meet locals and internationals.", + "start_url": "/", + "display": "standalone", + "background_color": "#FFFFFF", + "theme_color": "#FFD700", + "orientation": "portrait-primary", + "scope": "/", + "lang": "en", + "categories": ["events", "social", "education"], + "icons": [ + { + "src": "/images/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/images/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 8c47de5..0e95edd 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -225,16 +225,18 @@ export default function BookingPage() { newErrors.firstName = t('booking.form.errors.firstNameRequired'); } - if (!formData.lastName.trim() || formData.lastName.length < 2) { - newErrors.lastName = t('booking.form.errors.lastNameRequired'); + // lastName is optional - only validate if provided + if (formData.lastName.trim() && formData.lastName.length < 2) { + newErrors.lastName = t('booking.form.errors.lastNameTooShort'); } if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { newErrors.email = t('booking.form.errors.emailInvalid'); } - if (!formData.phone.trim() || formData.phone.length < 6) { - newErrors.phone = t('booking.form.errors.phoneRequired'); + // phone is optional - only validate if provided + if (formData.phone.trim() && formData.phone.length < 6) { + newErrors.phone = t('booking.form.errors.phoneTooShort'); } // RUC validation (optional field - only validate if filled) @@ -915,14 +917,22 @@ export default function BookingPage() { error={errors.firstName} required /> - setFormData({ ...formData, lastName: e.target.value })} - placeholder={t('booking.form.lastNamePlaceholder')} - error={errors.lastName} - required - /> +
+
+ + + ({locale === 'es' ? 'Opcional' : 'Optional'}) + +
+ setFormData({ ...formData, lastName: e.target.value })} + placeholder={t('booking.form.lastNamePlaceholder')} + error={errors.lastName} + /> +
@@ -938,14 +948,20 @@ export default function BookingPage() {
+
+ + + ({locale === 'es' ? 'Opcional' : 'Optional'}) + +
setFormData({ ...formData, phone: e.target.value })} placeholder={t('booking.form.phonePlaceholder')} error={errors.phone} - required />
diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx new file mode 100644 index 0000000..bf3abe8 --- /dev/null +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -0,0 +1,492 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + CheckCircleIcon, + ClockIcon, + XCircleIcon, + TicketIcon, + CreditCardIcon, + BuildingLibraryIcon, + ArrowTopRightOnSquareIcon, + CalendarIcon, + MapPinIcon, + CurrencyDollarIcon, +} from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; + +type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error'; + +export default function BookingPaymentPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const { locale } = useLanguage(); + const [ticket, setTicket] = useState(null); + const [paymentConfig, setPaymentConfig] = useState(null); + const [step, setStep] = useState('loading'); + const [markingPaid, setMarkingPaid] = useState(false); + const [error, setError] = useState(null); + + const ticketId = params.ticketId as string; + const requestedStep = searchParams.get('step'); + + // Fetch ticket and payment config + useEffect(() => { + if (!ticketId) return; + + const loadBookingData = async () => { + try { + // Get ticket with event and payment info + const { ticket: ticketData } = await ticketsApi.getById(ticketId); + + if (!ticketData) { + setError('Booking not found'); + setStep('error'); + return; + } + + setTicket(ticketData); + + // Only proceed for manual payment methods + const paymentMethod = ticketData.payment?.provider; + if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) { + // Not a manual payment method, redirect to success page or show appropriate state + if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') { + setStep('confirmed'); + } else { + setError('This booking does not support manual payment confirmation.'); + setStep('error'); + } + return; + } + + // Get payment config for the event + if (ticketData.eventId) { + const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId); + setPaymentConfig(paymentOptions); + } + + // Determine which step to show based on payment status + const paymentStatus = ticketData.payment?.status; + + if (paymentStatus === 'paid' || ticketData.status === 'confirmed') { + setStep('confirmed'); + } else if (paymentStatus === 'pending_approval') { + setStep('pending_approval'); + } else if (paymentStatus === 'pending') { + setStep('manual_payment'); + } else { + setError('Unable to determine payment status'); + setStep('error'); + } + } catch (err: any) { + console.error('Error loading booking:', err); + setError(err.message || 'Failed to load booking'); + setStep('error'); + } + }; + + loadBookingData(); + }, [ticketId]); + + // Handle "I Have Paid" button click + const handleMarkPaymentSent = async () => { + if (!ticket) return; + + // Check if already marked as paid + if (ticket.payment?.status === 'pending_approval' || ticket.payment?.status === 'paid') { + toast(locale === 'es' + ? 'El pago ya fue marcado como enviado.' + : 'Payment has already been marked as sent.', + { icon: 'ℹ️' } + ); + return; + } + + setMarkingPaid(true); + try { + await ticketsApi.markPaymentSent(ticket.id); + + // Update local state + setTicket(prev => prev ? { + ...prev, + payment: prev.payment ? { + ...prev.payment, + status: 'pending_approval', + userMarkedPaidAt: new Date().toISOString(), + } : prev.payment, + } : null); + + setStep('pending_approval'); + toast.success(locale === 'es' + ? 'Pago marcado como enviado. Esperando aprobación.' + : 'Payment marked as sent. Waiting for approval.'); + } catch (error: any) { + // Handle idempotency - if already processed, show appropriate message + if (error.message?.includes('already been processed')) { + toast(locale === 'es' + ? 'El pago ya fue procesado anteriormente.' + : 'Payment has already been processed.', + { icon: 'ℹ️' } + ); + // Refresh ticket data + const { ticket: refreshedTicket } = await ticketsApi.getById(ticket.id); + setTicket(refreshedTicket); + if (refreshedTicket.payment?.status === 'pending_approval') { + setStep('pending_approval'); + } else if (refreshedTicket.payment?.status === 'paid') { + setStep('confirmed'); + } + } else { + toast.error(error.message || 'Failed to mark payment as sent'); + } + } finally { + setMarkingPaid(false); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { + hour: '2-digit', + minute: '2-digit', + }); + }; + + // Loading state + if (step === 'loading') { + return ( +
+
+
+

+ {locale === 'es' ? 'Cargando tu reserva...' : 'Loading your booking...'} +

+
+
+ ); + } + + // Error state + if (step === 'error') { + return ( +
+
+ +
+ +
+

+ {locale === 'es' ? 'Reserva no encontrada' : 'Booking Not Found'} +

+

+ {error || (locale === 'es' + ? 'No pudimos encontrar tu reserva.' + : 'We could not find your booking.')} +

+ + + +
+
+
+ ); + } + + // Confirmed state + if (step === 'confirmed' && ticket) { + return ( +
+
+ +
+ +
+ +

+ {locale === 'es' ? '¡Reserva Confirmada!' : 'Booking Confirmed!'} +

+

+ {locale === 'es' + ? 'Tu pago ha sido verificado. ¡Te esperamos en el evento!' + : 'Your payment has been verified. See you at the event!'} +

+ +
+
+ + {ticket.qrCode} +
+ + {ticket.event && ( +
+

{locale === 'es' ? 'Evento' : 'Event'}: {ticket.event.title}

+

{locale === 'es' ? 'Fecha' : 'Date'}: {formatDate(ticket.event.startDatetime)}

+

{locale === 'es' ? 'Hora' : 'Time'}: {formatTime(ticket.event.startDatetime)}

+

{locale === 'es' ? 'Ubicación' : 'Location'}: {ticket.event.location}

+
+ )} +
+ +
+ + + + + + +
+
+
+
+ ); + } + + // Pending approval state + if (step === 'pending_approval' && ticket) { + return ( +
+
+ +
+ +
+ +

+ {locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'} +

+

+ {locale === 'es' + ? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.' + : 'We are verifying your payment. You will receive a confirmation email once approved.'} +

+ +
+
+ + {ticket.qrCode} +
+ + {ticket.event && ( +
+

{locale === 'es' ? 'Evento' : 'Event'}: {ticket.event.title}

+

{locale === 'es' ? 'Fecha' : 'Date'}: {formatDate(ticket.event.startDatetime)}

+

{locale === 'es' ? 'Hora' : 'Time'}: {formatTime(ticket.event.startDatetime)}

+

{locale === 'es' ? 'Ubicación' : 'Location'}: {ticket.event.location}

+
+ )} +
+ +
+

+ {locale === 'es' + ? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.' + : 'Payment verification may take up to 24 business hours. Please check your email regularly.'} +

+
+ +
+ + + + + + +
+
+
+
+ ); + } + + // Manual payment step - show payment details and "I have paid" button + if (step === 'manual_payment' && ticket && paymentConfig) { + const isBankTransfer = ticket.payment?.provider === 'bank_transfer'; + const isTpago = ticket.payment?.provider === 'tpago'; + + return ( +
+
+ {/* Event Summary Card */} + {ticket.event && ( + +
+

+ {locale === 'es' && ticket.event.titleEs ? ticket.event.titleEs : ticket.event.title} +

+
+
+
+ + {formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)} +
+
+ + {ticket.event.location} +
+
+ + + {ticket.event.price?.toLocaleString()} {ticket.event.currency} + +
+
+
+ )} + + {/* Payment Details Card */} + +
+
+ {isBankTransfer ? ( + + ) : ( + + )} +
+

+ {locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'} +

+

+ {locale === 'es' + ? 'Sigue las instrucciones para completar tu pago' + : 'Follow the instructions to complete your payment'} +

+
+ + {/* Amount to pay */} +
+

+ {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} +

+

+ {ticket.event?.price?.toLocaleString()} {ticket.event?.currency} +

+
+ + {/* Bank Transfer Details */} + {isBankTransfer && ( +
+

+ {locale === 'es' ? 'Datos Bancarios' : 'Bank Details'} +

+
+ {paymentConfig.bankName && ( +
+ {locale === 'es' ? 'Banco' : 'Bank'}: + {paymentConfig.bankName} +
+ )} + {paymentConfig.bankAccountHolder && ( +
+ {locale === 'es' ? 'Titular' : 'Account Holder'}: + {paymentConfig.bankAccountHolder} +
+ )} + {paymentConfig.bankAccountNumber && ( +
+ {locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}: + {paymentConfig.bankAccountNumber} +
+ )} + {paymentConfig.bankAlias && ( +
+ Alias: + {paymentConfig.bankAlias} +
+ )} + {paymentConfig.bankPhone && ( +
+ {locale === 'es' ? 'Teléfono' : 'Phone'}: + {paymentConfig.bankPhone} +
+ )} +
+ {(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && ( +

+ {locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes} +

+ )} +
+ )} + + {/* TPago Link */} + {isTpago && ( +
+

+ {locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'} +

+ {paymentConfig.tpagoLink && ( + + + {locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'} + + )} + {(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && ( +

+ {locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions} +

+ )} +
+ )} + + {/* Reference */} +
+

+ {locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'} +

+

{ticket.qrCode}

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

+ {locale === 'es' + ? 'Tu reserva será confirmada una vez que verifiquemos el pago' + : 'Your booking will be confirmed once we verify the payment'} +

+
+
+
+ ); + } + + // Fallback + return ( +
+
+

+ {locale === 'es' ? 'Cargando...' : 'Loading...'} +

+
+
+ ); +} diff --git a/frontend/src/app/(public)/community/layout.tsx b/frontend/src/app/(public)/community/layout.tsx new file mode 100644 index 0000000..51dac59 --- /dev/null +++ b/frontend/src/app/(public)/community/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Join Our Language Exchange Community', + description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', + openGraph: { + title: 'Join Our Language Exchange Community – Spanglish', + description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', + }, +}; + +export default function CommunityLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/frontend/src/app/(public)/contact/layout.tsx b/frontend/src/app/(public)/contact/layout.tsx new file mode 100644 index 0000000..91e100d --- /dev/null +++ b/frontend/src/app/(public)/contact/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Contact Us', + description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', + openGraph: { + title: 'Contact Us – Spanglish', + description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', + }, +}; + +export default function ContactLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx new file mode 100644 index 0000000..00ade58 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { eventsApi, Event } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import ShareButtons from '@/components/ShareButtons'; +import { + CalendarIcon, + MapPinIcon, + UserGroupIcon, + ArrowLeftIcon, +} from '@heroicons/react/24/outline'; + +interface EventDetailClientProps { + eventId: string; + initialEvent: Event; +} + +export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) { + const { t, locale } = useLanguage(); + const [event, setEvent] = useState(initialEvent); + + // Refresh event data on client for real-time availability + useEffect(() => { + eventsApi.getById(eventId) + .then(({ event }) => setEvent(event)) + .catch(console.error); + }, [eventId]); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { + hour: '2-digit', + minute: '2-digit', + }); + }; + + const isSoldOut = event.availableSeats === 0; + const isCancelled = event.status === 'cancelled'; + const isPastEvent = new Date(event.startDatetime) < new Date(); + const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; + + return ( +
+
+ + + {t('common.back')} + + +
+ {/* Event Details */} +
+ + {/* Banner */} + {event.bannerUrl ? ( + {`${event.title} + ) : ( +
+ +
+ )} + +
+
+

+ {locale === 'es' && event.titleEs ? event.titleEs : event.title} +

+ {isCancelled && ( + {t('events.details.cancelled')} + )} + {isSoldOut && !isCancelled && ( + {t('events.details.soldOut')} + )} +
+ +
+
+ +
+

{t('events.details.date')}

+

{formatDate(event.startDatetime)}

+
+
+ +
+ +
+

{t('events.details.time')}

+

{formatTime(event.startDatetime)}

+
+
+ +
+ +
+

{t('events.details.location')}

+

{event.location}

+ {event.locationUrl && ( + + View on map + + )} +
+
+ +
+ +
+

{t('events.details.capacity')}

+

+ {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} +

+
+
+
+ +
+

About this event

+

+ {locale === 'es' && event.descriptionEs + ? event.descriptionEs + : event.description} +

+
+ + {/* Social Sharing */} +
+ +
+
+
+
+ + {/* Booking Card */} +
+ +
+

{t('events.details.price')}

+

+ {event.price === 0 + ? t('events.details.free') + : `${event.price.toLocaleString()} ${event.currency}`} +

+
+ + {canBook ? ( + + + + ) : ( + + )} + +

+ {event.availableSeats} {t('events.details.spotsLeft')} +

+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 9bfffb2..38f4ff5 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -1,213 +1,147 @@ -'use client'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import EventDetailClient from './EventDetailClient'; -import { useState, useEffect } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { useLanguage } from '@/context/LanguageContext'; -import { eventsApi, Event } from '@/lib/api'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import ShareButtons from '@/components/ShareButtons'; -import { - CalendarIcon, - MapPinIcon, - UserGroupIcon, - ArrowLeftIcon, -} from '@heroicons/react/24/outline'; +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -export default function EventDetailPage() { - const params = useParams(); - const router = useRouter(); - const { t, locale } = useLanguage(); - const [event, setEvent] = useState(null); - const [loading, setLoading] = useState(true); +interface Event { + id: string; + title: string; + titleEs?: string; + description: string; + descriptionEs?: string; + startDatetime: string; + endDatetime?: string; + location: string; + locationUrl?: string; + price: number; + currency: string; + capacity: number; + status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; + bannerUrl?: string; + availableSeats?: number; + bookedCount?: number; + createdAt: string; + updatedAt: string; +} - useEffect(() => { - if (params.id) { - eventsApi.getById(params.id as string) - .then(({ event }) => setEvent(event)) - .catch(() => router.push('/events')) - .finally(() => setLoading(false)); - } - }, [params.id, router]); - - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', +async function getEvent(id: string): Promise { + try { + const response = await fetch(`${apiUrl}/api/events/${id}`, { + next: { revalidate: 60 }, }); - }; - - const formatTime = (dateStr: string) => { - return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { - hour: '2-digit', - minute: '2-digit', - }); - }; - - if (loading) { - return ( -
-
-
-
-
- ); - } - - if (!event) { + if (!response.ok) return null; + const data = await response.json(); + return data.event || null; + } catch { return null; } +} - const isSoldOut = event.availableSeats === 0; - const isCancelled = event.status === 'cancelled'; +export async function generateMetadata({ params }: { params: { id: string } }): Promise { + const event = await getEvent(params.id); + + if (!event) { + return { title: 'Event Not Found' }; + } + + const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const title = `${event.title} – English & Spanish Meetup in Asunción`; + const description = `Join Spanglish on ${eventDate} in Asunción. Practice English and Spanish in a relaxed social setting. Limited spots available.`; + + return { + title, + description, + openGraph: { + title, + description, + type: 'website', + url: `${siteUrl}/events/${event.id}`, + images: event.bannerUrl + ? [{ url: event.bannerUrl, width: 1200, height: 630, alt: event.title }] + : [{ url: `${siteUrl}/images/og-image.jpg`, width: 1200, height: 630, alt: 'Spanglish Language Exchange Event' }], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: event.bannerUrl ? [event.bannerUrl] : [`${siteUrl}/images/og-image.jpg`], + }, + alternates: { + canonical: `${siteUrl}/events/${event.id}`, + }, + }; +} + +function generateEventJsonLd(event: Event) { const isPastEvent = new Date(event.startDatetime) < new Date(); - const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; + const isCancelled = event.status === 'cancelled'; + + return { + '@context': 'https://schema.org', + '@type': 'Event', + name: event.title, + description: event.description, + startDate: event.startDatetime, + endDate: event.endDatetime || event.startDatetime, + eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode', + eventStatus: isCancelled + ? 'https://schema.org/EventCancelled' + : isPastEvent + ? 'https://schema.org/EventPostponed' + : 'https://schema.org/EventScheduled', + location: { + '@type': 'Place', + name: event.location, + address: { + '@type': 'PostalAddress', + addressLocality: 'Asunción', + addressCountry: 'PY', + }, + }, + organizer: { + '@type': 'Organization', + name: 'Spanglish', + url: siteUrl, + }, + offers: { + '@type': 'Offer', + price: event.price, + priceCurrency: event.currency, + availability: event.availableSeats && event.availableSeats > 0 + ? 'https://schema.org/InStock' + : 'https://schema.org/SoldOut', + url: `${siteUrl}/events/${event.id}`, + validFrom: new Date().toISOString(), + }, + image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, + url: `${siteUrl}/events/${event.id}`, + }; +} + +export default async function EventDetailPage({ params }: { params: { id: string } }) { + const event = await getEvent(params.id); + + if (!event) { + notFound(); + } + + const jsonLd = generateEventJsonLd(event); return ( -
-
- - - {t('common.back')} - - -
- {/* Event Details */} -
- - {/* Banner */} - {event.bannerUrl ? ( - {event.title} - ) : ( -
- -
- )} - -
-
-

- {locale === 'es' && event.titleEs ? event.titleEs : event.title} -

- {isCancelled && ( - {t('events.details.cancelled')} - )} - {isSoldOut && !isCancelled && ( - {t('events.details.soldOut')} - )} -
- -
-
- -
-

{t('events.details.date')}

-

{formatDate(event.startDatetime)}

-
-
- -
- -
-

{t('events.details.time')}

-

{formatTime(event.startDatetime)}

-
-
- -
- -
-

{t('events.details.location')}

-

{event.location}

- {event.locationUrl && ( - - View on map - - )} -
-
- -
- -
-

{t('events.details.capacity')}

-

- {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} -

-
-
-
- -
-

About this event

-

- {locale === 'es' && event.descriptionEs - ? event.descriptionEs - : event.description} -

-
- - {/* Social Sharing */} -
- -
-
-
-
- - {/* Booking Card */} -
- -
-

{t('events.details.price')}

-

- {event.price === 0 - ? t('events.details.free') - : `${event.price.toLocaleString()} ${event.currency}`} -

-
- - {canBook ? ( - - - - ) : ( - - )} - -

- {event.availableSeats} {t('events.details.spotsLeft')} -

-
-
-
-
-
+ <> +