Update site changes
This commit is contained in:
@@ -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.
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
BIN
frontend/public/images/favicon.png
Normal file
BIN
frontend/public/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/images/favicon_icon.png
Normal file
BIN
frontend/public/images/favicon_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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,7 +1051,8 @@ export default function BookingPage() {
|
|||||||
: 'No payment methods available for this event.'}
|
: 'No payment methods available for this event.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paymentMethods.map((method) => (
|
<>
|
||||||
|
{paymentMethods.map((method) => (
|
||||||
<button
|
<button
|
||||||
key={method.id}
|
key={method.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1057,7 +1093,48 @@ export default function BookingPage() {
|
|||||||
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
|
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -186,11 +186,23 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canBook ? (
|
{canBook ? (
|
||||||
|
event.externalBookingEnabled && event.externalBookingUrl ? (
|
||||||
|
<a
|
||||||
|
href={event.externalBookingUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
{t('events.booking.join')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<Link href={`/book/${event.id}`}>
|
<Link href={`/book/${event.id}`}>
|
||||||
<Button className="w-full" size="lg">
|
<Button className="w-full" size="lg">
|
||||||
{t('events.booking.join')}
|
{t('events.booking.join')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Button className="w-full" size="lg" disabled>
|
<Button className="w-full" size="lg" disabled>
|
||||||
{isPastEvent
|
{isPastEvent
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user