Add full SEO optimization for Spanglish social and language events
- Add comprehensive metadata to root layout with Open Graph, Twitter cards - Create dynamic sitemap.ts for all pages and events - Create robots.ts with proper allow/disallow rules - Add JSON-LD Event structured data to event detail pages - Add page-specific metadata to events, community, contact, FAQ pages - Add FAQ structured data schema - Update footer with local SEO text for Asunción, Paraguay - Add web manifest for mobile SEO - Create 404 page with proper noindex - Optimize image alt text and add lazy loading - Add NEXT_PUBLIC_SITE_URL env variable - Add about/ folder to gitignore
This commit is contained in:
@@ -11,6 +11,11 @@ DATABASE_URL=./data/spanglish.db
|
||||
# JWT Secret (change in production!)
|
||||
JWT_SECRET=your-super-secret-key-change-in-production
|
||||
|
||||
# Google OAuth (optional - for Google Sign-In)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# Note: The same Client ID should be used in frontend/.env
|
||||
GOOGLE_CLIENT_ID=
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
API_URL=http://localhost:3001
|
||||
|
||||
39
backend/scripts/migrate-users-nullable-password.sql
Normal file
39
backend/scripts/migrate-users-nullable-password.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Migration: Make password column nullable for Google OAuth users
|
||||
-- Run this on your production SQLite database:
|
||||
-- sqlite3 /path/to/spanglish.db < migrate-users-nullable-password.sql
|
||||
|
||||
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
|
||||
-- Step 1: Create new table with correct schema (password is nullable)
|
||||
CREATE TABLE users_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT, -- Now nullable for Google OAuth users
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
language_preference TEXT,
|
||||
is_claimed INTEGER NOT NULL DEFAULT 1,
|
||||
google_id TEXT,
|
||||
ruc_number TEXT,
|
||||
account_status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Step 2: Copy all existing data
|
||||
INSERT INTO users_new (id, email, password, name, phone, role, language_preference, is_claimed, google_id, ruc_number, account_status, created_at, updated_at)
|
||||
SELECT id, email, password, name, phone, role, language_preference, is_claimed, google_id, ruc_number, account_status, created_at, updated_at
|
||||
FROM users;
|
||||
|
||||
-- Step 3: Drop old table
|
||||
DROP TABLE users;
|
||||
|
||||
-- Step 4: Rename new table
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
|
||||
-- Step 5: Recreate indexes
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS users_google_id_idx ON users(google_id);
|
||||
|
||||
-- Done! Google OAuth users can now be created without passwords.
|
||||
@@ -216,6 +216,11 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Add allow_duplicate_bookings column to payment_options if it doesn't exist
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Event payment overrides table
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||
@@ -497,6 +502,11 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Add allow_duplicate_bookings column to payment_options if it doesn't exist
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||
id UUID PRIMARY KEY,
|
||||
|
||||
@@ -135,6 +135,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
cashInstructions: text('cash_instructions'),
|
||||
cashInstructionsEs: text('cash_instructions_es'),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
||||
// Metadata
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
@@ -369,6 +371,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
||||
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
||||
cashInstructions: pgText('cash_instructions'),
|
||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
@@ -26,13 +26,30 @@ const app = new Hono();
|
||||
// Middleware
|
||||
app.use('*', logger());
|
||||
|
||||
// CORS: Only enable in development. In production, nginx handles CORS.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('*', cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3002',
|
||||
// CORS
|
||||
// - In production we *typically* rely on nginx to set CORS, but enabling it here
|
||||
// is a safe fallback (especially for local/proxyless deployments).
|
||||
// - `FRONTEND_URL` should be set to e.g. https://spanglishcommunity.com in prod.
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const allowedOrigins = new Set<string>([
|
||||
frontendUrl,
|
||||
// Common alias (www) for the same site.
|
||||
frontendUrl.replace('://www.', '://'),
|
||||
frontendUrl.includes('://') ? frontendUrl.replace('://', '://www.') : frontendUrl,
|
||||
]);
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: (origin) => {
|
||||
// Non-browser / same-origin requests may omit Origin.
|
||||
if (!origin) return frontendUrl;
|
||||
return allowedOrigins.has(origin) ? origin : null;
|
||||
},
|
||||
// We use bearer tokens, but keeping credentials=true matches nginx config.
|
||||
credentials: true,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// OpenAPI specification
|
||||
const openApiSpec = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Email service for Spanglish platform
|
||||
// Supports multiple email providers: Resend, SMTP (Nodemailer)
|
||||
|
||||
import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js';
|
||||
import { db, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getNow } from './utils.js';
|
||||
@@ -372,17 +372,17 @@ export const emailService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Seed default templates if they don't exist
|
||||
* Seed default templates if they don't exist, and update system templates with latest content
|
||||
*/
|
||||
async seedDefaultTemplates(): Promise<void> {
|
||||
console.log('[Email] Checking for default templates...');
|
||||
|
||||
for (const template of defaultTemplates) {
|
||||
const existing = await this.getTemplate(template.slug);
|
||||
const now = getNow();
|
||||
|
||||
if (!existing) {
|
||||
console.log(`[Email] Creating template: ${template.name}`);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailTemplates).values({
|
||||
id: nanoid(),
|
||||
@@ -401,6 +401,24 @@ export const emailService = {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (existing.isSystem) {
|
||||
// Update system templates with latest content from defaults
|
||||
console.log(`[Email] Updating system template: ${template.name}`);
|
||||
|
||||
await (db as any)
|
||||
.update(emailTemplates)
|
||||
.set({
|
||||
subject: template.subject,
|
||||
subjectEs: template.subjectEs,
|
||||
bodyHtml: template.bodyHtml,
|
||||
bodyHtmlEs: template.bodyHtmlEs,
|
||||
bodyText: template.bodyText,
|
||||
bodyTextEs: template.bodyTextEs,
|
||||
description: template.description,
|
||||
variables: JSON.stringify(template.variables),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((emailTemplates as any).slug, template.slug));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,6 +633,159 @@ export const emailService = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get merged payment configuration for an event (global + overrides)
|
||||
*/
|
||||
async getPaymentConfig(eventId: string): Promise<Record<string, any>> {
|
||||
// Get global options
|
||||
const globalOptions = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await (db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
tpagoEnabled: false,
|
||||
tpagoLink: null,
|
||||
tpagoInstructions: null,
|
||||
tpagoInstructionsEs: null,
|
||||
bankTransferEnabled: false,
|
||||
bankName: null,
|
||||
bankAccountHolder: null,
|
||||
bankAccountNumber: null,
|
||||
bankAlias: null,
|
||||
bankPhone: null,
|
||||
bankNotes: null,
|
||||
bankNotesEs: null,
|
||||
};
|
||||
|
||||
const global = globalOptions || defaults;
|
||||
|
||||
// Merge: override values take precedence if they're not null/undefined
|
||||
return {
|
||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
||||
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||
bankName: overrides?.bankName ?? global.bankName,
|
||||
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
|
||||
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
|
||||
bankAlias: overrides?.bankAlias ?? global.bankAlias,
|
||||
bankPhone: overrides?.bankPhone ?? global.bankPhone,
|
||||
bankNotes: overrides?.bankNotes ?? global.bankNotes,
|
||||
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send payment instructions email (for TPago or Bank Transfer)
|
||||
* This email is sent immediately after user clicks "Continue to Payment"
|
||||
*/
|
||||
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Get payment
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Only send for manual payment methods
|
||||
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
|
||||
}
|
||||
|
||||
// Get merged payment config for this event
|
||||
const paymentConfig = await this.getPaymentConfig(event.id);
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Generate a payment reference using ticket ID
|
||||
const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
// Generate the booking URL for returning to payment page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
||||
|
||||
// Determine which template to use
|
||||
const templateSlug = payment.provider === 'tpago'
|
||||
? 'payment-instructions-tpago'
|
||||
: 'payment-instructions-bank-transfer';
|
||||
|
||||
// Build variables based on payment method
|
||||
const variables: Record<string, any> = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: this.formatCurrency(event.price, event.currency),
|
||||
paymentReference,
|
||||
bookingUrl,
|
||||
};
|
||||
|
||||
// Add payment-method specific variables
|
||||
if (payment.provider === 'tpago') {
|
||||
variables.tpagoLink = paymentConfig.tpagoLink || '';
|
||||
} else {
|
||||
// Bank transfer
|
||||
variables.bankName = paymentConfig.bankName || '';
|
||||
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
|
||||
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
|
||||
variables.bankAlias = paymentConfig.bankAlias || '';
|
||||
variables.bankPhone = paymentConfig.bankPhone || '';
|
||||
}
|
||||
|
||||
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
|
||||
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send custom email to event attendees
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,16 @@ export const paymentVariables: EmailVariable[] = [
|
||||
{ name: 'paymentDate', description: 'Payment date', example: 'January 28, 2026' },
|
||||
];
|
||||
|
||||
// Payment instructions variables (for manual payment methods)
|
||||
export const paymentInstructionsVariables: EmailVariable[] = [
|
||||
{ name: 'tpagoLink', description: 'TPago payment link', example: 'https://tpago.com.py/...' },
|
||||
{ name: 'bankName', description: 'Bank name', example: 'Banco Itaú' },
|
||||
{ name: 'bankAccountHolder', description: 'Account holder name', example: 'Spanglish SRL' },
|
||||
{ name: 'bankAccountNumber', description: 'Bank account number', example: '1234567890' },
|
||||
{ name: 'bankAlias', description: 'Bank alias or phone', example: '0981-123-456' },
|
||||
{ name: 'bankPhone', description: 'Bank phone number', example: '0981-123-456' },
|
||||
];
|
||||
|
||||
// Base HTML wrapper for all emails
|
||||
export const baseEmailWrapper = `
|
||||
<!DOCTYPE html>
|
||||
@@ -641,6 +651,319 @@ El Equipo de Spanglish`,
|
||||
],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'Payment Instructions - TPago',
|
||||
slug: 'payment-instructions-tpago',
|
||||
subject: 'Complete your payment for Spanglish',
|
||||
subjectEs: 'Completa tu pago para Spanglish',
|
||||
bodyHtml: `
|
||||
<h2>You're Almost In! 🎉</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
<p>To complete 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 class="event-detail"><strong>💰 Amount:</strong> {{paymentAmount}}</div>
|
||||
</div>
|
||||
|
||||
<p>Please complete your payment using TPago at the link below:</p>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{tpagoLink}}" class="btn" style="background-color: #3b82f6;">👉 Pay with Card</a>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
|
||||
If the button doesn't work: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
|
||||
</p>
|
||||
|
||||
<div class="note">
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{bookingUrl}}" class="btn">✓ I Have Paid</a>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
|
||||
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
|
||||
</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>See you soon,<br>Spanglish</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>¡Ya Casi Estás! 🎉</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
<p>Para completar 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 class="event-detail"><strong>💰 Monto:</strong> {{paymentAmount}}</div>
|
||||
</div>
|
||||
|
||||
<p>Por favor completa tu pago usando TPago en el siguiente enlace:</p>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{tpagoLink}}" class="btn" style="background-color: #3b82f6;">👉 Pagar con Tarjeta</a>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
|
||||
Si el botón no funciona: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
|
||||
</p>
|
||||
|
||||
<div class="note">
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{bookingUrl}}" class="btn">✓ Ya Pagué</a>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
|
||||
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
|
||||
</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>¡Nos vemos pronto!<br>Spanglish</p>
|
||||
`,
|
||||
bodyText: `You're Almost In!
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
To complete your booking for:
|
||||
|
||||
{{eventTitle}}
|
||||
📅 Date: {{eventDate}}
|
||||
📍 Location: {{eventLocation}}
|
||||
💰 Amount: {{paymentAmount}}
|
||||
|
||||
Please complete your payment using TPago at the link below:
|
||||
|
||||
👉 Pay with card: {{tpagoLink}}
|
||||
|
||||
After completing the payment, return to the website and click "I have paid" or use this link to notify us:
|
||||
|
||||
{{bookingUrl}}
|
||||
|
||||
Your spot will be confirmed once we verify the payment.
|
||||
|
||||
If you have any questions, just reply to this email.
|
||||
|
||||
See you soon,
|
||||
Spanglish`,
|
||||
bodyTextEs: `¡Ya Casi Estás!
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
Para completar tu reserva para:
|
||||
|
||||
{{eventTitle}}
|
||||
📅 Fecha: {{eventDate}}
|
||||
📍 Ubicación: {{eventLocation}}
|
||||
💰 Monto: {{paymentAmount}}
|
||||
|
||||
Por favor completa tu pago usando TPago en el siguiente enlace:
|
||||
|
||||
👉 Pagar con tarjeta: {{tpagoLink}}
|
||||
|
||||
Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
|
||||
|
||||
{{bookingUrl}}
|
||||
|
||||
Tu lugar será confirmado una vez que verifiquemos el pago.
|
||||
|
||||
Si tienes alguna pregunta, simplemente responde a este email.
|
||||
|
||||
¡Nos vemos pronto!
|
||||
Spanglish`,
|
||||
description: 'Sent when user selects TPago payment and clicks continue to payment',
|
||||
variables: [
|
||||
...commonVariables,
|
||||
...bookingVariables,
|
||||
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
|
||||
{ name: 'tpagoLink', description: 'TPago payment link', example: 'https://tpago.com.py/...' },
|
||||
{ name: 'bookingUrl', description: 'URL to return to payment page', example: 'https://spanglish.com/booking/abc123?step=payment' },
|
||||
],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'Payment Instructions - Bank Transfer',
|
||||
slug: 'payment-instructions-bank-transfer',
|
||||
subject: 'Bank transfer details for your Spanglish booking',
|
||||
subjectEs: 'Datos de transferencia para tu reserva en Spanglish',
|
||||
bodyHtml: `
|
||||
<h2>Thanks for Joining Spanglish! 🙂</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
<p>Here are the bank transfer details for your booking:</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>{{eventTitle}}</h3>
|
||||
<div class="event-detail"><strong>📅 Date:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>💰 Amount:</strong> {{paymentAmount}}</div>
|
||||
</div>
|
||||
|
||||
<div class="event-card" style="background-color: #ecfdf5; border: 1px solid #10b981;">
|
||||
<h3 style="color: #059669; margin-top: 0;">🏦 Bank Transfer Details</h3>
|
||||
{{#if bankName}}
|
||||
<div class="event-detail"><strong>Bank:</strong> {{bankName}}</div>
|
||||
{{/if}}
|
||||
{{#if bankAccountHolder}}
|
||||
<div class="event-detail"><strong>Account Holder:</strong> {{bankAccountHolder}}</div>
|
||||
{{/if}}
|
||||
{{#if bankAccountNumber}}
|
||||
<div class="event-detail"><strong>Account Number:</strong> <span style="font-family: monospace;">{{bankAccountNumber}}</span></div>
|
||||
{{/if}}
|
||||
{{#if bankAlias}}
|
||||
<div class="event-detail"><strong>Alias:</strong> {{bankAlias}}</div>
|
||||
{{/if}}
|
||||
{{#if bankPhone}}
|
||||
<div class="event-detail"><strong>Phone:</strong> {{bankPhone}}</div>
|
||||
{{/if}}
|
||||
<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 class="note">
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{bookingUrl}}" class="btn">✓ I Have Paid</a>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
|
||||
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
|
||||
</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>See you at the event,<br>Spanglish</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>¡Gracias por unirte a Spanglish! 🙂</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
<p>Aquí están los datos de transferencia para tu reserva:</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>{{eventTitle}}</h3>
|
||||
<div class="event-detail"><strong>📅 Fecha:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>💰 Monto:</strong> {{paymentAmount}}</div>
|
||||
</div>
|
||||
|
||||
<div class="event-card" style="background-color: #ecfdf5; border: 1px solid #10b981;">
|
||||
<h3 style="color: #059669; margin-top: 0;">🏦 Datos de Transferencia</h3>
|
||||
{{#if bankName}}
|
||||
<div class="event-detail"><strong>Banco:</strong> {{bankName}}</div>
|
||||
{{/if}}
|
||||
{{#if bankAccountHolder}}
|
||||
<div class="event-detail"><strong>Titular:</strong> {{bankAccountHolder}}</div>
|
||||
{{/if}}
|
||||
{{#if bankAccountNumber}}
|
||||
<div class="event-detail"><strong>Nro. Cuenta:</strong> <span style="font-family: monospace;">{{bankAccountNumber}}</span></div>
|
||||
{{/if}}
|
||||
{{#if bankAlias}}
|
||||
<div class="event-detail"><strong>Alias:</strong> {{bankAlias}}</div>
|
||||
{{/if}}
|
||||
{{#if bankPhone}}
|
||||
<div class="event-detail"><strong>Teléfono:</strong> {{bankPhone}}</div>
|
||||
{{/if}}
|
||||
<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 class="note">
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{bookingUrl}}" class="btn">✓ Ya Pagué</a>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
|
||||
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
|
||||
</p>
|
||||
|
||||
<p>Confirmaremos tu lugar tan pronto como recibamos el pago.</p>
|
||||
|
||||
<p>Si necesitas ayuda, responde a este email.</p>
|
||||
<p>¡Nos vemos en el evento!<br>Spanglish</p>
|
||||
`,
|
||||
bodyText: `Thanks for Joining Spanglish!
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
Here are the bank transfer details for your booking:
|
||||
|
||||
{{eventTitle}}
|
||||
📅 Date: {{eventDate}}
|
||||
💰 Amount: {{paymentAmount}}
|
||||
|
||||
Bank Transfer Details:
|
||||
- Bank: {{bankName}}
|
||||
- Account Holder: {{bankAccountHolder}}
|
||||
- Account Number: {{bankAccountNumber}}
|
||||
- Alias: {{bankAlias}}
|
||||
- Phone: {{bankPhone}}
|
||||
- Reference: {{paymentReference}}
|
||||
|
||||
After making the transfer, return to the website and click "I have paid" or use this link to notify us:
|
||||
|
||||
{{bookingUrl}}
|
||||
|
||||
We'll confirm your spot as soon as the payment is received.
|
||||
|
||||
If you need help, reply to this email.
|
||||
|
||||
See you at the event,
|
||||
Spanglish`,
|
||||
bodyTextEs: `¡Gracias por unirte a Spanglish!
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
Aquí están los datos de transferencia para tu reserva:
|
||||
|
||||
{{eventTitle}}
|
||||
📅 Fecha: {{eventDate}}
|
||||
💰 Monto: {{paymentAmount}}
|
||||
|
||||
Datos de Transferencia:
|
||||
- Banco: {{bankName}}
|
||||
- Titular: {{bankAccountHolder}}
|
||||
- Nro. Cuenta: {{bankAccountNumber}}
|
||||
- Alias: {{bankAlias}}
|
||||
- Teléfono: {{bankPhone}}
|
||||
- Referencia: {{paymentReference}}
|
||||
|
||||
Después de realizar la transferencia, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
|
||||
|
||||
{{bookingUrl}}
|
||||
|
||||
Confirmaremos tu lugar tan pronto como recibamos el pago.
|
||||
|
||||
Si necesitas ayuda, responde a este email.
|
||||
|
||||
¡Nos vemos en el evento!
|
||||
Spanglish`,
|
||||
description: 'Sent when user selects bank transfer payment and clicks continue to payment',
|
||||
variables: [
|
||||
...commonVariables,
|
||||
...bookingVariables,
|
||||
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
|
||||
{ name: 'paymentReference', description: 'Unique payment reference', example: 'SPG-ABC123' },
|
||||
{ name: 'bankName', description: 'Bank name', example: 'Banco Itaú' },
|
||||
{ name: 'bankAccountHolder', description: 'Account holder name', example: 'Spanglish SRL' },
|
||||
{ name: 'bankAccountNumber', description: 'Bank account number', example: '1234567890' },
|
||||
{ name: 'bankAlias', description: 'Bank alias', example: 'spanglish.py' },
|
||||
{ name: 'bankPhone', description: 'Bank phone number', example: '0981-123-456' },
|
||||
{ name: 'bookingUrl', description: 'URL to return to payment page', example: 'https://spanglish.com/booking/abc123?step=payment' },
|
||||
],
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to replace template variables
|
||||
|
||||
@@ -11,7 +11,8 @@ const mediaRouter = new Hono();
|
||||
|
||||
const UPLOAD_DIR = './uploads';
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_SIZE =
|
||||
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
||||
|
||||
// Ensure upload directory exists
|
||||
async function ensureUploadDir() {
|
||||
@@ -37,7 +38,8 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return c.json({ error: 'File too large. Maximum size: 5MB' }, 400);
|
||||
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
||||
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
||||
}
|
||||
|
||||
await ensureUploadDir();
|
||||
|
||||
@@ -26,6 +26,8 @@ const updatePaymentOptionsSchema = z.object({
|
||||
cashEnabled: z.boolean().optional(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema for event-level overrides
|
||||
@@ -75,6 +77,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, tickets, events, users, payments } from '../db/index.js';
|
||||
import { db, tickets, events, users, payments, paymentOptions } from '../db/index.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
||||
@@ -13,9 +13,9 @@ const ticketsRouter = new Hono();
|
||||
const createTicketSchema = z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(2),
|
||||
lastName: z.string().min(2),
|
||||
lastName: z.string().min(2).optional().or(z.literal('')),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(6, 'Phone number is required'),
|
||||
phone: z.string().min(6).optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
|
||||
@@ -76,7 +76,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
const now = getNow();
|
||||
|
||||
const fullName = `${data.firstName} ${data.lastName}`.trim();
|
||||
const fullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
: data.firstName;
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
@@ -94,20 +96,29 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for duplicate booking
|
||||
const existingTicket = await (db as any)
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const globalOptions = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'You have already booked this event' }, 400);
|
||||
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
if (!allowDuplicateBookings) {
|
||||
const existingTicket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'You have already booked this event' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
@@ -122,9 +133,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: ticketStatus,
|
||||
@@ -151,6 +162,20 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
|
||||
// Send payment instructions email for manual payment methods (TPago, Bank Transfer)
|
||||
if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) {
|
||||
// Send asynchronously - don't block the response
|
||||
emailService.sendPaymentInstructions(ticketId).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending payment instructions email:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// If Lightning payment, create LNbits invoice
|
||||
let lnbitsInvoice = null;
|
||||
if (data.paymentMethod === 'lightning' && event.price > 0) {
|
||||
@@ -389,6 +414,23 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
|
||||
}
|
||||
|
||||
// Handle idempotency - if already marked as sent or paid, return success with current state
|
||||
if (payment.status === 'pending_approval') {
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment was already marked as sent. Waiting for admin approval.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (payment.status === 'paid') {
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment has already been confirmed.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow if currently pending
|
||||
if (payment.status !== 'pending') {
|
||||
return c.json({ error: 'Payment has already been processed' }, 400);
|
||||
|
||||
Reference in New Issue
Block a user