Update site changes

This commit is contained in:
Michilis
2026-01-31 22:32:54 +00:00
parent d3c69f2936
commit 6df3baf0be
25 changed files with 764 additions and 137 deletions

View File

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

View File

@@ -83,11 +83,21 @@ async function migrate() {
capacity INTEGER NOT NULL DEFAULT 50, capacity INTEGER NOT NULL DEFAULT 50,
status TEXT NOT NULL DEFAULT 'draft', status TEXT NOT NULL DEFAULT 'draft',
banner_url TEXT, banner_url TEXT,
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -414,11 +424,21 @@ async function migrate() {
capacity INTEGER NOT NULL DEFAULT 50, capacity INTEGER NOT NULL DEFAULT 50,
status VARCHAR(20) NOT NULL DEFAULT 'draft', status VARCHAR(20) NOT NULL DEFAULT 'draft',
banner_url VARCHAR(500), banner_url VARCHAR(500),
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
external_booking_url VARCHAR(500),
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
updated_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` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,

View File

@@ -75,6 +75,8 @@ export const sqliteEvents = sqliteTable('events', {
capacity: integer('capacity').notNull().default(50), capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
@@ -315,6 +317,8 @@ export const pgEvents = pgTable('events', {
capacity: pgInteger('capacity').notNull().default(50), capacity: pgInteger('capacity').notNull().default(50),
status: varchar('status', { length: 20 }).notNull().default('draft'), status: varchar('status', { length: 20 }).notNull().default('draft'),
bannerUrl: varchar('banner_url', { length: 500 }), 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(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });

View File

@@ -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 * Send custom email to event attendees
*/ */

View File

@@ -673,6 +673,13 @@ El Equipo de Spanglish`,
If the button doesn't work: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a> If the button doesn't work: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
</p> </p>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Important - Manual Verification Process:</strong><br>
Please make sure you complete the payment before clicking "I have paid".<br>
The Spanglish team will review the payment manually.<br>
<strong>Your booking is only confirmed after you receive a confirmation email from us.</strong>
</div>
<div class="note"> <div class="note">
<strong>After completing the payment:</strong><br> <strong>After completing the payment:</strong><br>
Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us. Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us.
@@ -685,8 +692,6 @@ El Equipo de Spanglish`,
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a> Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p> </p>
<p>Your spot will be confirmed once we verify the payment.</p>
<p>If you have any questions, just reply to this email.</p> <p>If you have any questions, just reply to this email.</p>
<p>See you soon,<br>Spanglish</p> <p>See you soon,<br>Spanglish</p>
`, `,
@@ -711,6 +716,13 @@ El Equipo de Spanglish`,
Si el botón no funciona: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a> Si el botón no funciona: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
</p> </p>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Importante - Proceso de Verificación Manual:</strong><br>
Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".<br>
El equipo de Spanglish revisará el pago manualmente.<br>
<strong>Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.</strong>
</div>
<div class="note"> <div class="note">
<strong>Después de completar el pago:</strong><br> <strong>Después de completar el pago:</strong><br>
Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos. Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos.
@@ -723,8 +735,6 @@ El Equipo de Spanglish`,
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a> O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p> </p>
<p>Tu lugar será confirmado una vez que verifiquemos el pago.</p>
<p>Si tienes alguna pregunta, simplemente responde a este email.</p> <p>Si tienes alguna pregunta, simplemente responde a este email.</p>
<p>¡Nos vemos pronto!<br>Spanglish</p> <p>¡Nos vemos pronto!<br>Spanglish</p>
`, `,
@@ -743,12 +753,15 @@ Please complete your payment using TPago at the link below:
👉 Pay with card: {{tpagoLink}} 👉 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: After completing the payment, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}} {{bookingUrl}}
Your spot will be confirmed once we verify the payment.
If you have any questions, just reply to this email. If you have any questions, just reply to this email.
See you soon, See you soon,
@@ -768,12 +781,15 @@ Por favor completa tu pago usando TPago en el siguiente enlace:
👉 Pagar con tarjeta: {{tpagoLink}} 👉 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: Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}} {{bookingUrl}}
Tu lugar será confirmado una vez que verifiquemos el pago.
Si tienes alguna pregunta, simplemente responde a este email. Si tienes alguna pregunta, simplemente responde a este email.
¡Nos vemos pronto! ¡Nos vemos pronto!
@@ -824,6 +840,13 @@ Spanglish`,
<div class="event-detail"><strong>Reference:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div> <div class="event-detail"><strong>Reference:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div>
</div> </div>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Important - Manual Verification Process:</strong><br>
Please make sure you complete the payment before clicking "I have paid".<br>
The Spanglish team will review the payment manually.<br>
<strong>Your booking is only confirmed after you receive a confirmation email from us.</strong>
</div>
<div class="note"> <div class="note">
<strong>After making the transfer:</strong><br> <strong>After making the transfer:</strong><br>
Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us. Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us.
@@ -836,8 +859,6 @@ Spanglish`,
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a> Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p> </p>
<p>We'll confirm your spot as soon as the payment is received.</p>
<p>If you need help, reply to this email.</p> <p>If you need help, reply to this email.</p>
<p>See you at the event,<br>Spanglish</p> <p>See you at the event,<br>Spanglish</p>
`, `,
@@ -872,6 +893,13 @@ Spanglish`,
<div class="event-detail"><strong>Referencia:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div> <div class="event-detail"><strong>Referencia:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div>
</div> </div>
<div class="note" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<strong>Importante - Proceso de Verificación Manual:</strong><br>
Por favor asegúrate de completar el pago antes de hacer clic en "Ya pagué".<br>
El equipo de Spanglish revisará el pago manualmente.<br>
<strong>Tu reserva solo será confirmada después de que recibas un email de confirmación de nuestra parte.</strong>
</div>
<div class="note"> <div class="note">
<strong>Después de realizar la transferencia:</strong><br> <strong>Después de realizar la transferencia:</strong><br>
Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos. Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos.
@@ -884,8 +912,6 @@ Spanglish`,
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a> O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p> </p>
<p>Confirmaremos tu lugar tan pronto como recibamos el pago.</p>
<p>Si necesitas ayuda, responde a este email.</p> <p>Si necesitas ayuda, responde a este email.</p>
<p>¡Nos vemos en el evento!<br>Spanglish</p> <p>¡Nos vemos en el evento!<br>Spanglish</p>
`, `,
@@ -907,12 +933,15 @@ Bank Transfer Details:
- Phone: {{bankPhone}} - Phone: {{bankPhone}}
- Reference: {{paymentReference}} - 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: After making the transfer, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}} {{bookingUrl}}
We'll confirm your spot as soon as the payment is received.
If you need help, reply to this email. If you need help, reply to this email.
See you at the event, See you at the event,
@@ -935,12 +964,15 @@ Datos de Transferencia:
- Teléfono: {{bankPhone}} - Teléfono: {{bankPhone}}
- Referencia: {{paymentReference}} - 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}} {{bookingUrl}}
Confirmaremos tu lugar tan pronto como recibamos el pago.
Si necesitas ayuda, responde a este email. Si necesitas ayuda, responde a este email.
¡Nos vemos en el evento! ¡Nos vemos en el evento!
@@ -960,6 +992,117 @@ Spanglish`,
], ],
isSystem: true, 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: `
<h2>About Your Booking</h2>
<p>Hi {{attendeeName}},</p>
<p>Thanks for your interest in Spanglish.</p>
<p>Unfortunately, we were unable to find or confirm the payment for your booking for:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>📍 Location:</strong> {{eventLocation}}</div>
</div>
<p>Because of this, the booking has been cancelled.</p>
<div class="note">
<strong>Did you already pay?</strong><br>
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.
</div>
<p>You're always welcome to make a new booking if spots are still available.</p>
{{#if newBookingUrl}}
<p style="text-align: center; margin: 24px 0;">
<a href="{{newBookingUrl}}" class="btn">Make a New Booking</a>
</p>
{{/if}}
<p>Warm regards,<br>Spanglish</p>
`,
bodyHtmlEs: `
<h2>Sobre Tu Reserva</h2>
<p>Hola {{attendeeName}},</p>
<p>Gracias por tu interés en Spanglish.</p>
<p>Lamentablemente, no pudimos encontrar o confirmar el pago de tu reserva para:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>📍 Ubicación:</strong> {{eventLocation}}</div>
</div>
<p>Por esta razón, la reserva ha sido cancelada.</p>
<div class="note">
<strong>¿Ya realizaste el pago?</strong><br>
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.
</div>
<p>Siempre eres bienvenido/a a hacer una nueva reserva si aún hay lugares disponibles.</p>
{{#if newBookingUrl}}
<p style="text-align: center; margin: 24px 0;">
<a href="{{newBookingUrl}}" class="btn">Hacer una Nueva Reserva</a>
</p>
{{/if}}
<p>Saludos cordiales,<br>Spanglish</p>
`,
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 // Helper function to replace template variables

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; 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 { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.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), title: z.string().min(1),
titleEs: z.string().optional().nullable(), titleEs: z.string().optional().nullable(),
description: z.string().min(1), description: z.string().min(1),
@@ -38,9 +38,38 @@ const createEventSchema = z.object({
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'), status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs // Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')), 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) // Get all events (public)
eventsRouter.get('/', async (c) => { eventsRouter.get('/', async (c) => {
@@ -211,6 +240,44 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Event not found' }, 404); 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)); await (db as any).delete(events).where(eq((events as any).id, id));
return c.json({ message: 'Event deleted successfully' }); return c.json({ message: 'Event deleted successfully' });
@@ -257,6 +324,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
capacity: existing.capacity, capacity: existing.capacity,
status: 'draft', status: 'draft',
bannerUrl: existing.bannerUrl, bannerUrl: existing.bannerUrl,
externalBookingEnabled: existing.externalBookingEnabled || false,
externalBookingUrl: existing.externalBookingUrl,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };

View File

@@ -311,7 +311,21 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
}) })
.where(eq((payments as any).id, id)); .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) const updated = await (db as any)
.select() .select()
@@ -319,7 +333,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); .get();
return c.json({ payment: updated, message: 'Payment rejected' }); return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
}); });
// Update admin note // Update admin note

View File

@@ -19,6 +19,7 @@ NEXT_PUBLIC_WHATSAPP=+595991234567
NEXT_PUBLIC_INSTAGRAM=spanglish_py NEXT_PUBLIC_INSTAGRAM=spanglish_py
NEXT_PUBLIC_EMAIL=hola@spanglish.com.py NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
NEXT_PUBLIC_TELEGRAM=spanglish_py NEXT_PUBLIC_TELEGRAM=spanglish_py
NEXT_PUBLIC_TIKTOK=spanglishsocialpy
# Plausible Analytics (optional - leave empty to disable tracking) # Plausible Analytics (optional - leave empty to disable tracking)
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -160,6 +160,13 @@ export default function BookingPage() {
router.push('/events'); router.push('/events');
return; return;
} }
// Redirect to external booking if enabled
if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) {
window.location.href = eventRes.event.externalBookingUrl;
return;
}
setEvent(eventRes.event); setEvent(eventRes.event);
setPaymentConfig(paymentRes.paymentOptions); setPaymentConfig(paymentRes.paymentOptions);
@@ -696,6 +703,34 @@ export default function BookingPage() {
<p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p> <p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p>
</div> </div>
{/* Manual verification notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
</p>
<p className="text-blue-700">
{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.'}
</p>
</div>
</div>
</div>
{/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es'
? 'Solo haz clic aquí después de haber completado el pago.'
: 'Only click this after you have actually completed the payment.'}
</p>
{/* I Have Paid Button */} {/* I Have Paid Button */}
<Button <Button
onClick={handleMarkPaymentSent} onClick={handleMarkPaymentSent}
@@ -1016,48 +1051,90 @@ export default function BookingPage() {
: 'No payment methods available for this event.'} : 'No payment methods available for this event.'}
</div> </div>
) : ( ) : (
paymentMethods.map((method) => ( <>
<button {paymentMethods.map((method) => (
key={method.id} <button
type="button" key={method.id}
onClick={() => setFormData({ ...formData, paymentMethod: method.id })} type="button"
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${ onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
formData.paymentMethod === method.id className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
? 'border-primary-yellow bg-primary-yellow/10'
: 'border-secondary-light-gray hover:border-gray-300'
}`}
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
formData.paymentMethod === method.id
? 'bg-primary-yellow'
: 'bg-gray-100'
}`}>
<method.icon className={`w-5 h-5 ${
formData.paymentMethod === method.id formData.paymentMethod === method.id
? 'text-primary-dark' ? 'border-primary-yellow bg-primary-yellow/10'
: 'text-gray-500' : 'border-secondary-light-gray hover:border-gray-300'
}`} /> }`}
</div> >
<div className="flex-1"> <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
<div className="flex items-center gap-2"> formData.paymentMethod === method.id
<p className="font-medium text-primary-dark">{method.label}</p> ? 'bg-primary-yellow'
{method.badge && ( : 'bg-gray-100'
<span className={`text-xs px-2 py-0.5 rounded-full ${ }`}>
method.badge === 'Instant' || method.badge === 'Instantáneo' <method.icon className={`w-5 h-5 ${
? 'bg-green-100 text-green-700' formData.paymentMethod === method.id
: 'bg-gray-100 text-gray-600' ? 'text-primary-dark'
}`}> : 'text-gray-500'
{method.badge} }`} />
</span> </div>
)} <div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-primary-dark">{method.label}</p>
{method.badge && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
method.badge === 'Instant' || method.badge === 'Instantáneo'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{method.badge}
</span>
)}
</div>
<p className="text-sm text-gray-500">{method.description}</p>
</div>
{formData.paymentMethod === method.id && (
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
)}
</button>
))}
{/* Manual payment instructions - shown when TPago or Bank Transfer is selected */}
{(formData.paymentMethod === 'tpago' || formData.paymentMethod === 'bank_transfer') && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div className="text-sm text-amber-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Proceso de pago manual' : 'Manual payment process'}
</p>
<ol className="list-decimal list-inside space-y-1 text-amber-700">
<li>
{locale === 'es'
? 'Por favor completa el pago primero.'
: 'Please complete the payment first.'}
</li>
<li>
{locale === 'es'
? 'Después de pagar, haz clic en "Ya pagué" para notificarnos.'
: 'After you have paid, click "I have paid" to notify us.'}
</li>
<li>
{locale === 'es'
? 'Nuestro equipo verificará el pago manualmente.'
: 'Our team will manually verify the payment.'}
</li>
<li>
{locale === 'es'
? 'Una vez aprobado, recibirás un email confirmando tu reserva.'
: 'Once approved, you will receive an email confirming your booking.'}
</li>
</ol>
</div>
</div> </div>
<p className="text-sm text-gray-500">{method.description}</p>
</div> </div>
{formData.paymentMethod === method.id && ( )}
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" /> </>
)}
</button>
))
)} )}
</div> </div>
</Card> </Card>

View File

@@ -457,6 +457,34 @@ export default function BookingPaymentPage() {
<p className="font-mono font-bold text-lg">{ticket.qrCode}</p> <p className="font-mono font-bold text-lg">{ticket.qrCode}</p>
</div> </div>
{/* Manual verification notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
</p>
<p className="text-blue-700">
{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.'}
</p>
</div>
</div>
</div>
{/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es'
? 'Solo haz clic aquí después de haber completado el pago.'
: 'Only click this after you have actually completed the payment.'}
</p>
{/* I Have Paid Button */} {/* I Have Paid Button */}
<Button <Button
onClick={handleMarkPaymentSent} onClick={handleMarkPaymentSent}

View File

@@ -14,7 +14,8 @@ import {
socialConfig, socialConfig,
getWhatsAppUrl, getWhatsAppUrl,
getInstagramUrl, getInstagramUrl,
getTelegramUrl getTelegramUrl,
getTikTokUrl
} from '@/lib/socialLinks'; } from '@/lib/socialLinks';
export default function CommunityPage() { export default function CommunityPage() {
@@ -24,6 +25,7 @@ export default function CommunityPage() {
const whatsappUrl = getWhatsAppUrl(socialConfig.whatsapp); const whatsappUrl = getWhatsAppUrl(socialConfig.whatsapp);
const instagramUrl = getInstagramUrl(socialConfig.instagram); const instagramUrl = getInstagramUrl(socialConfig.instagram);
const telegramUrl = getTelegramUrl(socialConfig.telegram); const telegramUrl = getTelegramUrl(socialConfig.telegram);
const tiktokUrl = getTikTokUrl(socialConfig.tiktok);
const guidelines = t('community.guidelines.items') as unknown as string[]; const guidelines = t('community.guidelines.items') as unknown as string[];
@@ -36,7 +38,7 @@ export default function CommunityPage() {
</div> </div>
{/* Social Links */} {/* Social Links */}
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto"> <div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
{/* WhatsApp Card */} {/* WhatsApp Card */}
{whatsappUrl && ( {whatsappUrl && (
<Card className="p-8 text-center card-hover"> <Card className="p-8 text-center card-hover">
@@ -76,7 +78,7 @@ export default function CommunityPage() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block mt-6" className="inline-block mt-6"
> >
<Button variant="outline"> <Button>
{t('community.telegram.button')} {t('community.telegram.button')}
</Button> </Button>
</a> </a>
@@ -103,6 +105,29 @@ export default function CommunityPage() {
</a> </a>
</Card> </Card>
)} )}
{/* TikTok Card */}
{tiktokUrl && (
<Card className="p-8 text-center card-hover">
<div className="w-20 h-20 mx-auto bg-black rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
</div>
<h3 className="mt-6 text-xl font-semibold">{t('community.tiktok.title')}</h3>
<p className="mt-3 text-gray-600">{t('community.tiktok.description')}</p>
<a
href={tiktokUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-6"
>
<Button>
{t('community.tiktok.button')}
</Button>
</a>
</Card>
)}
</div> </div>
{/* Guidelines */} {/* Guidelines */}

View File

@@ -4,10 +4,6 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import {
ChatBubbleLeftRightIcon,
UserGroupIcon
} from '@heroicons/react/24/outline';
export default function HeroSection() { export default function HeroSection() {
const { t } = useLanguage(); const { t } = useLanguage();
@@ -51,8 +47,6 @@ export default function HeroSection() {
priority priority
fetchPriority="high" fetchPriority="high"
/> />
<div className="absolute inset-0 bg-primary-yellow/60" />
<ChatBubbleLeftRightIcon className="relative z-10 w-16 h-16 text-primary-dark opacity-50" />
</div> </div>
<div className="relative rounded-card h-48 overflow-hidden"> <div className="relative rounded-card h-48 overflow-hidden">
<Image <Image
@@ -76,7 +70,7 @@ export default function HeroSection() {
loading="lazy" loading="lazy"
/> />
</div> </div>
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden"> <div className="relative rounded-card h-32 overflow-hidden">
<Image <Image
src="/images/2026-01-29 13.09.59.jpg" src="/images/2026-01-29 13.09.59.jpg"
alt="Language exchange group practicing English and Spanish" alt="Language exchange group practicing English and Spanish"
@@ -85,8 +79,6 @@ export default function HeroSection() {
className="object-cover" className="object-cover"
loading="lazy" loading="lazy"
/> />
<div className="absolute inset-0 bg-secondary-brown/40" />
<UserGroupIcon className="relative z-10 w-16 h-16 text-secondary-brown opacity-70" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -186,11 +186,23 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</div> </div>
{canBook ? ( {canBook ? (
<Link href={`/book/${event.id}`}> event.externalBookingEnabled && event.externalBookingUrl ? (
<Button className="w-full" size="lg"> <a
{t('events.booking.join')} href={event.externalBookingUrl}
</Button> target="_blank"
</Link> rel="noopener noreferrer"
>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</a>
) : (
<Link href={`/book/${event.id}`}>
<Button className="w-full" size="lg">
{t('events.booking.join')}
</Button>
</Link>
)
) : ( ) : (
<Button className="w-full" size="lg" disabled> <Button className="w-full" size="lg" disabled>
{isPastEvent {isPastEvent

View File

@@ -34,6 +34,8 @@ export default function AdminEventsPage() {
capacity: number; capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string; bannerUrl: string;
externalBookingEnabled: boolean;
externalBookingUrl: string;
}>({ }>({
title: '', title: '',
titleEs: '', titleEs: '',
@@ -48,6 +50,8 @@ export default function AdminEventsPage() {
capacity: 50, capacity: 50,
status: 'draft', status: 'draft',
bannerUrl: '', bannerUrl: '',
externalBookingEnabled: false,
externalBookingUrl: '',
}); });
useEffect(() => { useEffect(() => {
@@ -80,6 +84,8 @@ export default function AdminEventsPage() {
capacity: 50, capacity: 50,
status: 'draft' as const, status: 'draft' as const,
bannerUrl: '', bannerUrl: '',
externalBookingEnabled: false,
externalBookingUrl: '',
}); });
setEditingEvent(null); setEditingEvent(null);
}; };
@@ -99,6 +105,8 @@ export default function AdminEventsPage() {
capacity: event.capacity, capacity: event.capacity,
status: event.status, status: event.status,
bannerUrl: event.bannerUrl || '', bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '',
}); });
setEditingEvent(event); setEditingEvent(event);
setShowForm(true); setShowForm(true);
@@ -109,6 +117,18 @@ export default function AdminEventsPage() {
setSaving(true); setSaving(true);
try { try {
// Validate external booking URL if enabled
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
toast.error('External booking URL is required when external booking is enabled');
setSaving(false);
return;
}
if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) {
toast.error('External booking URL must be a valid HTTPS link');
setSaving(false);
return;
}
const eventData = { const eventData = {
title: formData.title, title: formData.title,
titleEs: formData.titleEs || undefined, titleEs: formData.titleEs || undefined,
@@ -123,6 +143,8 @@ export default function AdminEventsPage() {
capacity: formData.capacity, capacity: formData.capacity,
status: formData.status, status: formData.status,
bannerUrl: formData.bannerUrl || undefined, bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
}; };
if (editingEvent) { if (editingEvent) {
@@ -340,6 +362,43 @@ export default function AdminEventsPage() {
</select> </select>
</div> </div>
{/* External Booking Section */}
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">External Booking</label>
<p className="text-xs text-gray-500">Redirect users to an external booking platform</p>
</div>
<button
type="button"
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 ${
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{formData.externalBookingEnabled && (
<div>
<Input
label="External Booking URL"
type="url"
value={formData.externalBookingUrl}
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
placeholder="https://example.com/book"
required
/>
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
</div>
)}
</div>
{/* Image Upload / Media Picker */} {/* Image Upload / Media Picker */}
<MediaPicker <MediaPicker
value={formData.bannerUrl} value={formData.bannerUrl}

View File

@@ -9,10 +9,35 @@
body { body {
@apply font-sans text-primary-dark antialiased; @apply font-sans text-primary-dark antialiased;
@apply font-normal; /* Regular (400) for body */
@apply leading-relaxed; /* Good line spacing */
} }
h1, h2, h3, h4, h5, h6 { /* Titles: Medium (500) / SemiBold (600) with good spacing */
@apply font-heading; h1 {
@apply font-semibold tracking-tight leading-tight;
}
h2 {
@apply font-semibold tracking-tight leading-tight;
}
h3 {
@apply font-semibold leading-snug;
}
h4, h5, h6 {
@apply font-medium leading-snug;
}
/* Paragraphs with good spacing */
p {
@apply leading-relaxed;
}
/* Buttons: Medium weight */
button, .btn, [type="button"], [type="submit"], [type="reset"] {
@apply font-medium;
} }
} }
@@ -26,11 +51,11 @@
} }
.section-title { .section-title {
@apply text-3xl md:text-4xl font-bold text-primary-dark; @apply text-3xl md:text-4xl font-semibold text-primary-dark tracking-tight;
} }
.section-subtitle { .section-subtitle {
@apply text-lg text-gray-600 mt-4; @apply text-lg text-gray-600 mt-4 leading-relaxed;
} }
/* Form styles */ /* Form styles */

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Inter, Poppins } from 'next/font/google'; import { Poppins } from 'next/font/google';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { LanguageProvider } from '@/context/LanguageContext'; import { LanguageProvider } from '@/context/LanguageContext';
import { AuthProvider } from '@/context/AuthContext'; import { AuthProvider } from '@/context/AuthContext';
@@ -7,18 +7,14 @@ import PlausibleAnalytics from '@/components/PlausibleAnalytics';
import './globals.css'; import './globals.css';
// Self-hosted fonts via next/font - eliminates render-blocking external requests // Self-hosted fonts via next/font - eliminates render-blocking external requests
const inter = Inter({ // Poppins: entire site
subsets: ['latin'], // - Titles: Medium (500) / SemiBold (600)
display: 'swap', // - Body: Regular (400)
variable: '--font-inter',
weight: ['400', '500', '600', '700'],
});
const poppins = Poppins({ const poppins = Poppins({
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
variable: '--font-poppins', variable: '--font-poppins',
weight: ['500', '600', '700'], weight: ['400', '500', '600', '700'],
}); });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
@@ -95,6 +91,10 @@ export const metadata: Metadata = {
}, },
category: 'events', category: 'events',
manifest: '/manifest.json', manifest: '/manifest.json',
icons: {
icon: '/images/favicon.png',
apple: '/images/favicon_icon.png',
},
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
@@ -110,8 +110,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" className={`${inter.variable} ${poppins.variable}`}> <html lang="en" className={poppins.variable}>
<body className={inter.className}> <body className={poppins.className}>
<PlausibleAnalytics /> <PlausibleAnalytics />
<AuthProvider> <AuthProvider>
<LanguageProvider> <LanguageProvider>

View File

@@ -19,6 +19,7 @@ export default function LinktreePage() {
const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP; const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP;
const instagramHandle = process.env.NEXT_PUBLIC_INSTAGRAM; const instagramHandle = process.env.NEXT_PUBLIC_INSTAGRAM;
const telegramHandle = process.env.NEXT_PUBLIC_TELEGRAM; const telegramHandle = process.env.NEXT_PUBLIC_TELEGRAM;
const tiktokHandle = process.env.NEXT_PUBLIC_TIKTOK;
useEffect(() => { useEffect(() => {
eventsApi.getNextUpcoming() eventsApi.getNextUpcoming()
@@ -51,6 +52,10 @@ export default function LinktreePage() {
? (telegramHandle.startsWith('http') ? telegramHandle : `https://t.me/${telegramHandle.replace('@', '')}`) ? (telegramHandle.startsWith('http') ? telegramHandle : `https://t.me/${telegramHandle.replace('@', '')}`)
: null; : null;
const tiktokUrl = tiktokHandle
? (tiktokHandle.startsWith('http') ? tiktokHandle : `https://www.tiktok.com/@${tiktokHandle.replace('@', '')}`)
: null;
return ( return (
<div className="min-h-screen bg-gradient-to-b from-primary-dark via-gray-900 to-primary-dark"> <div className="min-h-screen bg-gradient-to-b from-primary-dark via-gray-900 to-primary-dark">
<div className="max-w-md mx-auto px-4 py-8 pb-16"> <div className="max-w-md mx-auto px-4 py-8 pb-16">
@@ -195,6 +200,31 @@ export default function LinktreePage() {
</svg> </svg>
</a> </a>
)} )}
{/* TikTok */}
{tiktokUrl && (
<a
href={tiktokUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-white/20 hover:border-white/30 hover:scale-[1.02] group"
>
<div className="w-12 h-12 bg-black rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white group-hover:text-white transition-colors">
{t('linktree.tiktok.title')}
</p>
<p className="text-sm text-gray-400">{t('linktree.tiktok.subtitle')}</p>
</div>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
)}
</div> </div>
{/* Website Link */} {/* Website Link */}

View File

@@ -28,6 +28,7 @@ interface AuthContextType {
logout: () => void; logout: () => void;
updateUser: (user: User) => void; updateUser: (user: User) => void;
setAuthData: (data: { user: User; token: string }) => void; setAuthData: (data: { user: User; token: string }) => void;
refreshUser: () => Promise<void>;
} }
interface RegisterData { interface RegisterData {
@@ -48,6 +49,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
// Load auth state from localStorage // Load auth state from localStorage
const savedToken = localStorage.getItem(TOKEN_KEY); const savedToken = localStorage.getItem(TOKEN_KEY);
@@ -56,9 +86,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (savedToken && savedUser) { if (savedToken && savedUser) {
setToken(savedToken); setToken(savedToken);
setUser(JSON.parse(savedUser)); 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 }) => { const setAuthData = useCallback((data: { user: User; token: string }) => {
setToken(data.token); setToken(data.token);
@@ -159,6 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout, logout,
updateUser, updateUser,
setAuthData, setAuthData,
refreshUser,
}} }}
> >
{children} {children}

View File

@@ -164,6 +164,11 @@
"description": "Join our Telegram channel for news and announcements", "description": "Join our Telegram channel for news and announcements",
"button": "Join Telegram" "button": "Join Telegram"
}, },
"tiktok": {
"title": "TikTok",
"description": "Watch our videos and follow us for fun content",
"button": "Follow Us"
},
"guidelines": { "guidelines": {
"title": "Community Guidelines", "title": "Community Guidelines",
"items": [ "items": [
@@ -316,6 +321,10 @@
"instagram": { "instagram": {
"title": "Instagram", "title": "Instagram",
"subtitle": "Photos & stories" "subtitle": "Photos & stories"
},
"tiktok": {
"title": "TikTok",
"subtitle": "Videos & fun content"
} }
} }
} }

View File

@@ -164,6 +164,11 @@
"description": "Únete a nuestro canal de Telegram para noticias y anuncios", "description": "Únete a nuestro canal de Telegram para noticias y anuncios",
"button": "Unirse a Telegram" "button": "Unirse a Telegram"
}, },
"tiktok": {
"title": "TikTok",
"description": "Mira nuestros videos y síguenos para contenido divertido",
"button": "Seguirnos"
},
"guidelines": { "guidelines": {
"title": "Reglas de la Comunidad", "title": "Reglas de la Comunidad",
"items": [ "items": [
@@ -316,6 +321,10 @@
"instagram": { "instagram": {
"title": "Instagram", "title": "Instagram",
"subtitle": "Fotos e historias" "subtitle": "Fotos e historias"
},
"tiktok": {
"title": "TikTok",
"subtitle": "Videos y contenido divertido"
} }
} }
} }

View File

@@ -422,6 +422,8 @@ export interface Event {
capacity: number; capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string; bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
bookedCount?: number; bookedCount?: number;
availableSeats?: number; availableSeats?: number;
createdAt: string; createdAt: string;

View File

@@ -6,10 +6,11 @@ export interface SocialLinks {
instagram?: string; instagram?: string;
email?: string; email?: string;
telegram?: string; telegram?: string;
tiktok?: string;
} }
export interface SocialLink { export interface SocialLink {
type: 'whatsapp' | 'instagram' | 'email' | 'telegram'; type: 'whatsapp' | 'instagram' | 'email' | 'telegram' | 'tiktok';
url: string; url: string;
label: string; label: string;
handle?: string; handle?: string;
@@ -21,6 +22,7 @@ export const socialConfig: SocialLinks = {
instagram: process.env.NEXT_PUBLIC_INSTAGRAM || undefined, instagram: process.env.NEXT_PUBLIC_INSTAGRAM || undefined,
email: process.env.NEXT_PUBLIC_EMAIL || undefined, email: process.env.NEXT_PUBLIC_EMAIL || undefined,
telegram: process.env.NEXT_PUBLIC_TELEGRAM || undefined, telegram: process.env.NEXT_PUBLIC_TELEGRAM || undefined,
tiktok: process.env.NEXT_PUBLIC_TIKTOK || undefined,
}; };
// Generate URLs from handles/values // Generate URLs from handles/values
@@ -62,6 +64,17 @@ export function getTelegramUrl(value?: string): string | null {
return `https://t.me/${clean}`; 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 // Extract display handle from URL or value
function extractInstagramHandle(value: string): string { function extractInstagramHandle(value: string): string {
// If it's a URL, extract the username from the path // If it's a URL, extract the username from the path
@@ -83,6 +96,20 @@ function extractTelegramHandle(value: string): string {
return `@${value.replace('@', '')}`; 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 // Get all active social links as an array
export function getSocialLinks(): SocialLink[] { export function getSocialLinks(): SocialLink[] {
const links: 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; return links;
} }
@@ -160,4 +199,9 @@ export const socialIcons = {
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/> <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg> </svg>
), ),
tiktok: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
),
}; };

View File

@@ -25,8 +25,9 @@ module.exports = {
}, },
}, },
fontFamily: { fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], // Poppins everywhere
heading: ['var(--font-poppins)', 'var(--font-inter)', 'system-ui', 'sans-serif'], sans: ['var(--font-poppins)', 'system-ui', 'sans-serif'],
heading: ['var(--font-poppins)', 'system-ui', 'sans-serif'],
}, },
borderRadius: { borderRadius: {
'btn': '12px', 'btn': '12px',