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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ coverage/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
# Documentation (internal)
|
||||
about/
|
||||
|
||||
@@ -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,7 +96,15 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for duplicate booking
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const globalOptions = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
|
||||
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
if (!allowDuplicateBookings) {
|
||||
const existingTicket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
@@ -109,6 +119,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'You have already booked this event' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
const ticketId = generateId();
|
||||
@@ -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);
|
||||
|
||||
@@ -24,6 +24,10 @@ server {
|
||||
|
||||
server_name api.spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (avoid nginx 413 on media uploads)
|
||||
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
|
||||
client_max_body_size 20m;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
@@ -39,34 +43,44 @@ server {
|
||||
access_log /var/log/nginx/spanglish_api_access.log;
|
||||
error_log /var/log/nginx/spanglish_api_error.log;
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
# CORS Configuration
|
||||
# CORS Configuration (set once, used everywhere)
|
||||
set $cors_origin "";
|
||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||
set $cors_origin $http_origin;
|
||||
}
|
||||
|
||||
# Handle preflight OPTIONS requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Add CORS headers to all responses
|
||||
# Add CORS headers to all responses (including nginx-generated errors)
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
|
||||
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
|
||||
error_page 413 = @payload_too_large;
|
||||
location @payload_too_large {
|
||||
default_type application/json;
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
# Handle preflight OPTIONS requests
|
||||
# NOTE: add_header inside if{} does NOT inherit server-level headers,
|
||||
# so we must repeat all CORS headers here.
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://spanglish_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -75,6 +89,13 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Strip CORS headers from backend (nginx handles CORS at server level)
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ server {
|
||||
|
||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||
client_max_body_size 20m;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
# Frontend port (dev/start)
|
||||
PORT=3002
|
||||
|
||||
# Site URL (for SEO canonical URLs, sitemap, etc.)
|
||||
NEXT_PUBLIC_SITE_URL=https://spanglish.com.py
|
||||
|
||||
# API URL (leave empty for same-origin proxy)
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||
# 2. Add authorized JavaScript origins: http://localhost:3002, https://yourdomain.com
|
||||
# 3. Add authorized redirect URIs: http://localhost:3002, https://yourdomain.com
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
|
||||
# Social Links (optional - leave empty to hide)
|
||||
NEXT_PUBLIC_WHATSAPP=+595991234567
|
||||
NEXT_PUBLIC_INSTAGRAM=spanglish_py
|
||||
NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
|
||||
NEXT_PUBLIC_TELEGRAM=spanglish_py
|
||||
|
||||
# Plausible Analytics (optional - leave empty to disable tracking)
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com
|
||||
|
||||
27
frontend/public/manifest.json
Normal file
27
frontend/public/manifest.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Spanglish – Language Exchange Events in Asunción",
|
||||
"short_name": "Spanglish",
|
||||
"description": "Practice English and Spanish at relaxed social events in Asunción, Paraguay. Meet locals and internationals.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFFFFF",
|
||||
"theme_color": "#FFD700",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"lang": "en",
|
||||
"categories": ["events", "social", "education"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/images/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -225,16 +225,18 @@ export default function BookingPage() {
|
||||
newErrors.firstName = t('booking.form.errors.firstNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.lastName.trim() || formData.lastName.length < 2) {
|
||||
newErrors.lastName = t('booking.form.errors.lastNameRequired');
|
||||
// lastName is optional - only validate if provided
|
||||
if (formData.lastName.trim() && formData.lastName.length < 2) {
|
||||
newErrors.lastName = t('booking.form.errors.lastNameTooShort');
|
||||
}
|
||||
|
||||
if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = t('booking.form.errors.emailInvalid');
|
||||
}
|
||||
|
||||
if (!formData.phone.trim() || formData.phone.length < 6) {
|
||||
newErrors.phone = t('booking.form.errors.phoneRequired');
|
||||
// phone is optional - only validate if provided
|
||||
if (formData.phone.trim() && formData.phone.length < 6) {
|
||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||
}
|
||||
|
||||
// RUC validation (optional field - only validate if filled)
|
||||
@@ -915,15 +917,23 @@ export default function BookingPage() {
|
||||
error={errors.firstName}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.lastName')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
label={t('booking.form.lastName')}
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
||||
error={errors.lastName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
@@ -938,14 +948,20 @@ export default function BookingPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.phone')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
label={t('booking.form.phone')}
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder={t('booking.form.phonePlaceholder')}
|
||||
error={errors.phone}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
492
frontend/src/app/(public)/booking/[ticketId]/page.tsx
Normal file
492
frontend/src/app/(public)/booking/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
TicketIcon,
|
||||
CreditCardIcon,
|
||||
BuildingLibraryIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
CurrencyDollarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||
|
||||
export default function BookingPaymentPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const { locale } = useLanguage();
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [paymentConfig, setPaymentConfig] = useState<PaymentOptionsConfig | null>(null);
|
||||
const [step, setStep] = useState<PaymentStep>('loading');
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ticketId = params.ticketId as string;
|
||||
const requestedStep = searchParams.get('step');
|
||||
|
||||
// Fetch ticket and payment config
|
||||
useEffect(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const loadBookingData = async () => {
|
||||
try {
|
||||
// Get ticket with event and payment info
|
||||
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
||||
|
||||
if (!ticketData) {
|
||||
setError('Booking not found');
|
||||
setStep('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setTicket(ticketData);
|
||||
|
||||
// Only proceed for manual payment methods
|
||||
const paymentMethod = ticketData.payment?.provider;
|
||||
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
|
||||
// Not a manual payment method, redirect to success page or show appropriate state
|
||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
} else {
|
||||
setError('This booking does not support manual payment confirmation.');
|
||||
setStep('error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get payment config for the event
|
||||
if (ticketData.eventId) {
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
|
||||
setPaymentConfig(paymentOptions);
|
||||
}
|
||||
|
||||
// Determine which step to show based on payment status
|
||||
const paymentStatus = ticketData.payment?.status;
|
||||
|
||||
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
|
||||
setStep('confirmed');
|
||||
} else if (paymentStatus === 'pending_approval') {
|
||||
setStep('pending_approval');
|
||||
} else if (paymentStatus === 'pending') {
|
||||
setStep('manual_payment');
|
||||
} else {
|
||||
setError('Unable to determine payment status');
|
||||
setStep('error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error loading booking:', err);
|
||||
setError(err.message || 'Failed to load booking');
|
||||
setStep('error');
|
||||
}
|
||||
};
|
||||
|
||||
loadBookingData();
|
||||
}, [ticketId]);
|
||||
|
||||
// Handle "I Have Paid" button click
|
||||
const handleMarkPaymentSent = async () => {
|
||||
if (!ticket) return;
|
||||
|
||||
// Check if already marked as paid
|
||||
if (ticket.payment?.status === 'pending_approval' || ticket.payment?.status === 'paid') {
|
||||
toast(locale === 'es'
|
||||
? 'El pago ya fue marcado como enviado.'
|
||||
: 'Payment has already been marked as sent.',
|
||||
{ icon: 'ℹ️' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMarkingPaid(true);
|
||||
try {
|
||||
await ticketsApi.markPaymentSent(ticket.id);
|
||||
|
||||
// Update local state
|
||||
setTicket(prev => prev ? {
|
||||
...prev,
|
||||
payment: prev.payment ? {
|
||||
...prev.payment,
|
||||
status: 'pending_approval',
|
||||
userMarkedPaidAt: new Date().toISOString(),
|
||||
} : prev.payment,
|
||||
} : null);
|
||||
|
||||
setStep('pending_approval');
|
||||
toast.success(locale === 'es'
|
||||
? 'Pago marcado como enviado. Esperando aprobación.'
|
||||
: 'Payment marked as sent. Waiting for approval.');
|
||||
} catch (error: any) {
|
||||
// Handle idempotency - if already processed, show appropriate message
|
||||
if (error.message?.includes('already been processed')) {
|
||||
toast(locale === 'es'
|
||||
? 'El pago ya fue procesado anteriormente.'
|
||||
: 'Payment has already been processed.',
|
||||
{ icon: 'ℹ️' }
|
||||
);
|
||||
// Refresh ticket data
|
||||
const { ticket: refreshedTicket } = await ticketsApi.getById(ticket.id);
|
||||
setTicket(refreshedTicket);
|
||||
if (refreshedTicket.payment?.status === 'pending_approval') {
|
||||
setStep('pending_approval');
|
||||
} else if (refreshedTicket.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
}
|
||||
} else {
|
||||
toast.error(error.message || 'Failed to mark payment as sent');
|
||||
}
|
||||
} finally {
|
||||
setMarkingPaid(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (step === 'loading') {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
<p className="mt-4 text-gray-600">
|
||||
{locale === 'es' ? 'Cargando tu reserva...' : 'Loading your booking...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (step === 'error') {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-6">
|
||||
<XCircleIcon className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'Reserva no encontrada' : 'Booking Not Found'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{error || (locale === 'es'
|
||||
? 'No pudimos encontrar tu reserva.'
|
||||
: 'We could not find your booking.')}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>{locale === 'es' ? 'Ver Eventos' : 'Browse Events'}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirmed state
|
||||
if (step === 'confirmed' && ticket) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircleIcon className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¡Reserva Confirmada!' : 'Booking Confirmed!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Tu pago ha sido verificado. ¡Te esperamos en el evento!'
|
||||
: 'Your payment has been verified. See you at the event!'}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<span className="font-mono text-lg font-bold">{ticket.qrCode}</span>
|
||||
</div>
|
||||
|
||||
{ticket.event && (
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
<Button variant="outline">{locale === 'es' ? 'Ver Más Eventos' : 'Browse More Events'}</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button>{locale === 'es' ? 'Volver al Inicio' : 'Back to Home'}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending approval state
|
||||
if (step === 'pending_approval' && ticket) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
|
||||
<ClockIcon className="w-10 h-10 text-yellow-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
|
||||
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<span className="font-mono text-lg font-bold">{ticket.qrCode}</span>
|
||||
</div>
|
||||
|
||||
{ticket.event && (
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
||||
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
{locale === 'es'
|
||||
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
|
||||
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
<Button variant="outline">{locale === 'es' ? 'Ver Más Eventos' : 'Browse More Events'}</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button>{locale === 'es' ? 'Volver al Inicio' : 'Back to Home'}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual payment step - show payment details and "I have paid" button
|
||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||
const isTpago = ticket.payment?.provider === 'tpago';
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
{/* Event Summary Card */}
|
||||
{ticket.event && (
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
|
||||
<h2 className="font-bold text-lg text-primary-dark">
|
||||
{locale === 'es' && ticket.event.titleEs ? ticket.event.titleEs : ticket.event.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{ticket.event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span className="font-bold text-lg">
|
||||
{ticket.event.price?.toLocaleString()} {ticket.event.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Payment Details Card */}
|
||||
<Card className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
|
||||
{isBankTransfer ? (
|
||||
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
|
||||
) : (
|
||||
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Sigue las instrucciones para completar tu pago'
|
||||
: 'Follow the instructions to complete your payment'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Amount to pay */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{ticket.event?.price?.toLocaleString()} {ticket.event?.currency}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Details */}
|
||||
{isBankTransfer && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
|
||||
</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
||||
{paymentConfig.bankName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankName}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAccountHolder && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAccountNumber && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
|
||||
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAlias && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Alias:</span>
|
||||
<span className="font-medium">{paymentConfig.bankAlias}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankPhone && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TPago Link */}
|
||||
{isTpago && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
||||
</h3>
|
||||
{paymentConfig.tpagoLink && (
|
||||
<a
|
||||
href={paymentConfig.tpagoLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
|
||||
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
|
||||
</a>
|
||||
)}
|
||||
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference */}
|
||||
<div className="bg-gray-100 rounded-lg p-3 mb-6">
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
|
||||
</p>
|
||||
<p className="font-mono font-bold text-lg">{ticket.qrCode}</p>
|
||||
</div>
|
||||
|
||||
{/* I Have Paid Button */}
|
||||
<Button
|
||||
onClick={handleMarkPaymentSent}
|
||||
isLoading={markingPaid}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-gray-500 mt-4">
|
||||
{locale === 'es'
|
||||
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
|
||||
: 'Your booking will be confirmed once we verify the payment'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es' ? 'Cargando...' : 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/app/(public)/community/layout.tsx
Normal file
18
frontend/src/app/(public)/community/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join Our Language Exchange Community',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
openGraph: {
|
||||
title: 'Join Our Language Exchange Community – Spanglish',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function CommunityLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
18
frontend/src/app/(public)/contact/layout.tsx
Normal file
18
frontend/src/app/(public)/contact/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
openGraph: {
|
||||
title: 'Contact Us – Spanglish',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
199
frontend/src/app/(public)/events/[id]/EventDetailClient.tsx
Normal file
199
frontend/src/app/(public)/events/[id]/EventDetailClient.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ArrowLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface EventDetailClientProps {
|
||||
eventId: string;
|
||||
initialEvent: Event;
|
||||
}
|
||||
|
||||
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
||||
const { t, locale } = useLanguage();
|
||||
const [event, setEvent] = useState<Event>(initialEvent);
|
||||
|
||||
// Refresh event data on client for real-time availability
|
||||
useEffect(() => {
|
||||
eventsApi.getById(eventId)
|
||||
.then(({ event }) => setEvent(event))
|
||||
.catch(console.error);
|
||||
}, [eventId]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const isSoldOut = event.availableSeats === 0;
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
const isPastEvent = new Date(event.startDatetime) < new Date();
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
{/* Banner */}
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
className="h-64 w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h1>
|
||||
{isCancelled && (
|
||||
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||
)}
|
||||
{isSoldOut && !isCancelled && (
|
||||
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.date')}</p>
|
||||
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.time')}</p>
|
||||
<p className="text-gray-600">{formatTime(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.location')}</p>
|
||||
<p className="text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:underline text-sm"
|
||||
>
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||
<p className="text-gray-700 whitespace-pre-line">
|
||||
{locale === 'es' && event.descriptionEs
|
||||
? event.descriptionEs
|
||||
: event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<ShareButtons
|
||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="p-6 sticky top-24">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canBook ? (
|
||||
<Link href={`/book/${event.id}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +1,147 @@
|
||||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import EventDetailClient from './EventDetailClient';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ArrowLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useLanguage();
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description: string;
|
||||
descriptionEs?: string;
|
||||
startDatetime: string;
|
||||
endDatetime?: string;
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl?: string;
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
eventsApi.getById(params.id as string)
|
||||
.then(({ event }) => setEvent(event))
|
||||
.catch(() => router.push('/events'))
|
||||
.finally(() => setLoading(false));
|
||||
async function getEvent(id: string): Promise<Event | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events/${id}`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.event || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [params.id, router]);
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||
const event = await getEvent(params.id);
|
||||
|
||||
if (!event) {
|
||||
return { title: 'Event Not Found' };
|
||||
}
|
||||
|
||||
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const title = `${event.title} – English & Spanish Meetup in Asunción`;
|
||||
const description = `Join Spanglish on ${eventDate} in Asunción. Practice English and Spanish in a relaxed social setting. Limited spots available.`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
images: event.bannerUrl
|
||||
? [{ url: event.bannerUrl, width: 1200, height: 630, alt: event.title }]
|
||||
: [{ url: `${siteUrl}/images/og-image.jpg`, width: 1200, height: 630, alt: 'Spanglish Language Exchange Event' }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: event.bannerUrl ? [event.bannerUrl] : [`${siteUrl}/images/og-image.jpg`],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/events/${event.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateEventJsonLd(event: Event) {
|
||||
const isPastEvent = new Date(event.startDatetime) < new Date();
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: event.title,
|
||||
description: event.description,
|
||||
startDate: event.startDatetime,
|
||||
endDate: event.endDatetime || event.startDatetime,
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: isCancelled
|
||||
? 'https://schema.org/EventCancelled'
|
||||
: isPastEvent
|
||||
? 'https://schema.org/EventPostponed'
|
||||
: 'https://schema.org/EventScheduled',
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: event.location,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Asunción',
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
},
|
||||
organizer: {
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
url: siteUrl,
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: event.price,
|
||||
priceCurrency: event.currency,
|
||||
availability: event.availableSeats && event.availableSeats > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
validFrom: new Date().toISOString(),
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: { id: string } }) {
|
||||
const event = await getEvent(params.id);
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isSoldOut = event.availableSeats === 0;
|
||||
const isCancelled = event.status === 'cancelled';
|
||||
const isPastEvent = new Date(event.startDatetime) < new Date();
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||
const jsonLd = generateEventJsonLd(event);
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
{/* Banner */}
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
className="h-64 w-full object-cover"
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h1>
|
||||
{isCancelled && (
|
||||
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||
)}
|
||||
{isSoldOut && !isCancelled && (
|
||||
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.date')}</p>
|
||||
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.time')}</p>
|
||||
<p className="text-gray-600">{formatTime(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.location')}</p>
|
||||
<p className="text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:underline text-sm"
|
||||
>
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||
<p className="text-gray-700 whitespace-pre-line">
|
||||
{locale === 'es' && event.descriptionEs
|
||||
? event.descriptionEs
|
||||
: event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<ShareButtons
|
||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="p-6 sticky top-24">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canBook ? (
|
||||
<Link href={`/book/${event.id}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EventDetailClient eventId={params.id} initialEvent={event} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
18
frontend/src/app/(public)/events/layout.tsx
Normal file
18
frontend/src/app/(public)/events/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Upcoming Language Exchange Events in Asunción',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
openGraph: {
|
||||
title: 'Upcoming Language Exchange Events in Asunción – Spanglish',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function EventsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -108,8 +108,9 @@ export default function EventsPage() {
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
className="h-40 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
|
||||
|
||||
66
frontend/src/app/(public)/faq/layout.tsx
Normal file
66
frontend/src/app/(public)/faq/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// FAQ Page structured data
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Spanglish?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Who can attend Spanglish events?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How do language exchange events work?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Our events typically last 2-3 hours. You will be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Do I need to speak the language already?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frequently Asked Questions',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.',
|
||||
openGraph: {
|
||||
title: 'Frequently Asked Questions – Spanglish',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function FAQLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
siteName: 'Spanglish',
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
alternateLocale: 'es_PY',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
|
||||
// JSON-LD Organization schema for all public pages
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/logo.png`,
|
||||
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Asunción',
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
sameAs: [
|
||||
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
|
||||
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
||||
].filter(Boolean),
|
||||
};
|
||||
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -8,6 +43,10 @@ export default function PublicLayout({
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
|
||||
/>
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -13,6 +13,8 @@ export async function generateStaticParams() {
|
||||
return slugs.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const legalPage = getLegalPage(params.slug);
|
||||
@@ -24,8 +26,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${legalPage.title} | Spanglish`,
|
||||
description: `${legalPage.title} for Spanglish - Language Exchange Community in Paraguay`,
|
||||
title: `${legalPage.title} – Spanglish`,
|
||||
description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/legal/${params.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import { authApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: any) => void;
|
||||
renderButton: (element: HTMLElement | null, options: any) => void;
|
||||
prompt: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t, locale: language } = useLanguage();
|
||||
const { login, loginWithGoogle } = useAuth();
|
||||
const { login } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
@@ -42,47 +28,6 @@ function LoginContent() {
|
||||
// Check for redirect after login
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||
|
||||
// Initialize Google Sign-In
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.google) {
|
||||
initializeGoogleSignIn();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initializeGoogleSignIn = () => {
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
if (!clientId || !window.google) return;
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: handleGoogleCallback,
|
||||
});
|
||||
|
||||
const buttonElement = document.getElementById('google-signin-button');
|
||||
if (buttonElement) {
|
||||
window.google.accounts.id.renderButton(buttonElement, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'continue_with',
|
||||
width: '100%',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleCallback = async (response: { credential: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginWithGoogle(response.credential);
|
||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||
router.push(redirectTo);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (language === 'es' ? 'Error de inicio de sesión' : 'Login failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -122,13 +67,6 @@ function LoginContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://accounts.google.com/gsi/client"
|
||||
strategy="afterInteractive"
|
||||
onLoad={initializeGoogleSignIn}
|
||||
/>
|
||||
|
||||
<div className="section-padding min-h-[70vh] flex items-center">
|
||||
<div className="container-page">
|
||||
<div className="max-w-md mx-auto">
|
||||
@@ -139,7 +77,11 @@ function LoginContent() {
|
||||
|
||||
<Card className="p-8">
|
||||
{/* Google Sign-In Button */}
|
||||
<div id="google-signin-button" className="mb-4 flex justify-center"></div>
|
||||
<GoogleSignInButton
|
||||
redirectTo={redirectTo}
|
||||
text="continue_with"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="relative my-6">
|
||||
@@ -266,7 +208,6 @@ function LoginContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,10 +98,12 @@ export default function HomePage() {
|
||||
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.08.jpg"
|
||||
alt="Language exchange event"
|
||||
alt="Spanglish language exchange social event in Asunción"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="eager"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary-yellow/60" />
|
||||
<ChatBubbleLeftRightIcon className="relative z-10 w-16 h-16 text-primary-dark opacity-50" />
|
||||
@@ -109,10 +111,11 @@ export default function HomePage() {
|
||||
<div className="relative rounded-card h-48 overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.23.jpg"
|
||||
alt="Group language practice"
|
||||
alt="English and Spanish language practice session in Asunción"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,19 +123,21 @@ export default function HomePage() {
|
||||
<div className="relative rounded-card h-48 overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.10.16.jpg"
|
||||
alt="Community meetup"
|
||||
alt="Spanglish community meetup in Paraguay"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src="/images/2026-01-29 13.09.59.jpg"
|
||||
alt="Language exchange group"
|
||||
alt="Language exchange group practicing English and Spanish"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover"
|
||||
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" />
|
||||
|
||||
@@ -8,11 +8,12 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const { t, locale: language } = useLanguage();
|
||||
const { register } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -28,8 +29,8 @@ export default function RegisterPage() {
|
||||
|
||||
try {
|
||||
await register(formData);
|
||||
toast.success('Account created successfully!');
|
||||
router.push('/');
|
||||
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
|
||||
router.push('/dashboard');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('auth.errors.emailExists'));
|
||||
} finally {
|
||||
@@ -47,6 +48,25 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<Card className="p-8">
|
||||
{/* Google Sign-In Button */}
|
||||
<GoogleSignInButton
|
||||
redirectTo="/dashboard"
|
||||
text="signup_with"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">
|
||||
{language === 'es' ? 'o registrarse con email' : 'or register with email'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
id="name"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, mediaApi, Event } from '@/lib/api';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, ArrowUpTrayIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -18,8 +19,6 @@ export default function AdminEventsPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
@@ -166,25 +165,6 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, editingEvent?.id, 'event');
|
||||
// Use proxied path so it works through Next.js rewrites
|
||||
setFormData({ ...formData, bannerUrl: result.url });
|
||||
toast.success('Image uploaded successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
@@ -360,53 +340,13 @@ export default function AdminEventsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{formData.bannerUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.bannerUrl}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
{/* Image Upload / Media Picker */}
|
||||
<MediaPicker
|
||||
value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id}
|
||||
relatedType="event"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, bannerUrl: '' })}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-8 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<PhotoIcon className="w-12 h-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Click to upload event image</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP (max 5MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
TicketIcon,
|
||||
Cog6ToothIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -38,6 +40,7 @@ export default function PaymentOptionsPage() {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -399,6 +402,66 @@ export default function PaymentOptionsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Booking Settings */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Cog6ToothIcon className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Configuración de Reservas' : 'Booking Settings'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Opciones adicionales para el proceso de reserva' : 'Additional options for the booking process'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
{/* Allow Duplicate Bookings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TicketIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{locale === 'es' ? 'Permitir Reservas Múltiples' : 'Allow Multiple Bookings'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Permitir que un usuario reserve varios tickets para el mismo evento con el mismo email'
|
||||
: 'Allow a user to book multiple tickets for the same event with the same email'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('allowDuplicateBookings', !options.allowDuplicateBookings)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.allowDuplicateBookings ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.allowDuplicateBookings ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.allowDuplicateBookings && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 ml-8">
|
||||
<p className="text-sm text-yellow-800">
|
||||
{locale === 'es'
|
||||
? '⚠️ Cuando está habilitado, los usuarios pueden crear múltiples reservas para el mismo evento. Esto es útil para reservar en nombre de amigos o familiares.'
|
||||
: '⚠️ When enabled, users can create multiple bookings for the same event. This is useful for booking on behalf of friends or family.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -1,20 +1,91 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { LanguageProvider } from '@/context/LanguageContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import PlausibleAnalytics from '@/components/PlausibleAnalytics';
|
||||
import './globals.css';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Spanglish - Language Exchange in Asunción',
|
||||
description: 'Practice English and Spanish with native speakers at our language exchange events in Asunción, Paraguay.',
|
||||
keywords: ['language exchange', 'Spanish', 'English', 'Asunción', 'Paraguay', 'intercambio de idiomas'],
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Spanglish – Language Exchange Events in Asunción',
|
||||
template: '%s – Spanglish',
|
||||
},
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
keywords: [
|
||||
'language exchange',
|
||||
'Spanglish',
|
||||
'Spanglish social',
|
||||
'English Spanish meetup',
|
||||
'language exchange Asunción',
|
||||
'practice English Asunción',
|
||||
'intercambio de idiomas',
|
||||
'intercambio de idiomas Asunción',
|
||||
'English Spanish Paraguay',
|
||||
'language events Paraguay',
|
||||
'Asunción',
|
||||
'Paraguay',
|
||||
],
|
||||
authors: [{ name: 'Spanglish' }],
|
||||
creator: 'Spanglish',
|
||||
publisher: 'Spanglish',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Spanglish - Language Exchange in Asunción',
|
||||
description: 'Practice English and Spanish with native speakers at our language exchange events.',
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
url: siteUrl,
|
||||
siteName: 'Spanglish',
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
alternateLocale: 'es_ES',
|
||||
alternateLocale: 'es_PY',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.jpg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Spanglish – Language Exchange Events in Asunción, Paraguay',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.',
|
||||
images: ['/images/og-image.jpg'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
canonical: siteUrl,
|
||||
languages: {
|
||||
'en': siteUrl,
|
||||
'es': `${siteUrl}/es`,
|
||||
},
|
||||
},
|
||||
category: 'events',
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#FFD700',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,6 +96,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<PlausibleAnalytics />
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
{children}
|
||||
|
||||
41
frontend/src/app/not-found.tsx
Normal file
41
frontend/src/app/not-found.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found – Spanglish',
|
||||
description: 'The page you are looking for could not be found.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-gray">
|
||||
<div className="text-center px-4">
|
||||
<h1 className="text-6xl font-bold text-primary-dark mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border-2 border-primary-dark text-primary-dark font-semibold rounded-btn hover:bg-primary-dark hover:text-white transition-colors"
|
||||
>
|
||||
View Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/app/robots.ts
Normal file
38
frontend/src/app/robots.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: [
|
||||
'/',
|
||||
'/events',
|
||||
'/events/*',
|
||||
'/community',
|
||||
'/contact',
|
||||
'/faq',
|
||||
'/legal/*',
|
||||
],
|
||||
disallow: [
|
||||
'/admin',
|
||||
'/admin/*',
|
||||
'/dashboard',
|
||||
'/dashboard/*',
|
||||
'/api',
|
||||
'/api/*',
|
||||
'/book',
|
||||
'/book/*',
|
||||
'/booking',
|
||||
'/booking/*',
|
||||
'/login',
|
||||
'/register',
|
||||
'/auth/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
91
frontend/src/app/sitemap.ts
Normal file
91
frontend/src/app/sitemap.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
status: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function getPublishedEvents(): Promise<Event[]> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published`, {
|
||||
next: { revalidate: 3600 }, // Cache for 1 hour
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.events || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Fetch published events for dynamic event pages
|
||||
const events = await getPublishedEvents();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: siteUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/events`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/community`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/faq`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
// Legal pages
|
||||
{
|
||||
url: `${siteUrl}/legal/terms-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/privacy-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic event pages
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [...staticPages, ...eventPages];
|
||||
}
|
||||
216
frontend/src/components/GoogleSignInButton.tsx
Normal file
216
frontend/src/components/GoogleSignInButton.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: GoogleInitConfig) => void;
|
||||
renderButton: (element: HTMLElement | null, options: GoogleButtonOptions) => void;
|
||||
prompt: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleInitConfig {
|
||||
client_id: string;
|
||||
callback: (response: GoogleCredentialResponse) => void;
|
||||
auto_select?: boolean;
|
||||
cancel_on_tap_outside?: boolean;
|
||||
}
|
||||
|
||||
interface GoogleButtonOptions {
|
||||
type?: 'standard' | 'icon';
|
||||
theme?: 'outline' | 'filled_blue' | 'filled_black';
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
||||
logo_alignment?: 'left' | 'center';
|
||||
width?: string | number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface GoogleCredentialResponse {
|
||||
credential: string;
|
||||
select_by?: string;
|
||||
}
|
||||
|
||||
interface GoogleSignInButtonProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
redirectTo?: string;
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GoogleSignInButton({
|
||||
onSuccess,
|
||||
onError,
|
||||
redirectTo = '/dashboard',
|
||||
text = 'continue_with',
|
||||
className = '',
|
||||
}: GoogleSignInButtonProps) {
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||
const [scriptError, setScriptError] = useState(false);
|
||||
const { loginWithGoogle } = useAuth();
|
||||
const { locale } = useLanguage();
|
||||
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const handleGoogleCallback = useCallback(
|
||||
async (response: GoogleCredentialResponse) => {
|
||||
if (!response.credential) {
|
||||
const errorMsg = locale === 'es' ? 'No se recibio credencial de Google' : 'No credential received from Google';
|
||||
onError?.(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginWithGoogle(response.credential);
|
||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||
onSuccess?.();
|
||||
|
||||
// Use window.location for navigation to ensure clean state
|
||||
if (redirectTo) {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||
onError?.(displayError);
|
||||
toast.error(displayError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[loginWithGoogle, locale, onSuccess, onError, redirectTo]
|
||||
);
|
||||
|
||||
const initializeGoogleSignIn = useCallback(() => {
|
||||
if (!clientId) {
|
||||
console.warn('Google Client ID not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google?.accounts?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: handleGoogleCallback,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
|
||||
if (buttonRef.current) {
|
||||
// Clear any existing button
|
||||
buttonRef.current.innerHTML = '';
|
||||
|
||||
window.google.accounts.id.renderButton(buttonRef.current, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: text,
|
||||
shape: 'rectangular',
|
||||
logo_alignment: 'left',
|
||||
width: 280,
|
||||
locale: locale === 'es' ? 'es' : 'en',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing Google Sign-In:', error);
|
||||
setScriptError(true);
|
||||
}
|
||||
}, [clientId, handleGoogleCallback, text, locale]);
|
||||
|
||||
// Load Google Sign-In script
|
||||
useEffect(() => {
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script is already loaded
|
||||
if (window.google?.accounts?.id) {
|
||||
setScriptLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script tag already exists
|
||||
const existingScript = document.querySelector('script[src="https://accounts.google.com/gsi/client"]');
|
||||
if (existingScript) {
|
||||
// Script exists but may not be loaded yet
|
||||
existingScript.addEventListener('load', () => setScriptLoaded(true));
|
||||
existingScript.addEventListener('error', () => setScriptError(true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setScriptLoaded(true);
|
||||
script.onerror = () => setScriptError(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// Cleanup is handled by checking for existing script
|
||||
};
|
||||
}, [clientId]);
|
||||
|
||||
// Initialize when script is loaded
|
||||
useEffect(() => {
|
||||
if (scriptLoaded) {
|
||||
// Small delay to ensure Google object is fully available
|
||||
const timer = setTimeout(initializeGoogleSignIn, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [scriptLoaded, initializeGoogleSignIn]);
|
||||
|
||||
// Don't render if no client ID configured
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scriptError) {
|
||||
return (
|
||||
<div className={`text-center text-sm text-gray-500 py-2 ${className}`}>
|
||||
{locale === 'es'
|
||||
? 'Google Sign-In no disponible'
|
||||
: 'Google Sign-In unavailable'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Google Sign-In Button Container */}
|
||||
<div
|
||||
ref={buttonRef}
|
||||
className="flex justify-center min-h-[44px]"
|
||||
aria-label={locale === 'es' ? 'Iniciar sesion con Google' : 'Sign in with Google'}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center rounded">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-secondary-blue" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/MediaPicker.tsx
Normal file
277
frontend/src/components/MediaPicker.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { mediaApi, Media } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
PhotoIcon,
|
||||
ArrowUpTrayIcon,
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
FolderOpenIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface MediaPickerProps {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
relatedId?: string;
|
||||
relatedType?: string;
|
||||
}
|
||||
|
||||
export default function MediaPicker({ value, onChange, relatedId, relatedType = 'event' }: MediaPickerProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'library'>('upload');
|
||||
const [media, setMedia] = useState<Media[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedMedia, setSelectedMedia] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal && activeTab === 'library') {
|
||||
loadMedia();
|
||||
}
|
||||
}, [showModal, activeTab]);
|
||||
|
||||
const loadMedia = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { media } = await mediaApi.getAll();
|
||||
setMedia(media);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load media library');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, relatedId, relatedType);
|
||||
onChange(result.url);
|
||||
toast.success('Image uploaded successfully');
|
||||
setShowModal(false);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFromLibrary = () => {
|
||||
if (selectedMedia) {
|
||||
onChange(selectedMedia);
|
||||
setShowModal(false);
|
||||
setSelectedMedia(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{value ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={value}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 border-2 border-dashed border-secondary-light-gray rounded-btn p-6 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<ArrowUpTrayIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Upload New</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(true); setActiveTab('library'); }}
|
||||
className="flex-1 border-2 border-dashed border-secondary-light-gray rounded-btn p-6 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<FolderOpenIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Choose from Library</p>
|
||||
<p className="text-xs text-gray-400">Select existing media</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media Library Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-btn w-full max-w-4xl max-h-[80vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold">Select Image</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(false); setSelectedMedia(null); }}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'upload'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ArrowUpTrayIcon className="w-4 h-4 inline-block mr-2" />
|
||||
Upload New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('library')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'library'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<FolderOpenIcon className="w-4 h-4 inline-block mr-2" />
|
||||
Media Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'upload' && (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-12 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-12 h-12 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-4 text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<PhotoIcon className="w-16 h-16 text-gray-400" />
|
||||
<p className="mt-4 text-lg text-gray-600">Click to upload an image</p>
|
||||
<p className="text-sm text-gray-400 mt-1">JPEG, PNG, GIF, WebP (max 10MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'library' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : media.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<PhotoIcon className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2">No images in library</p>
|
||||
<p className="text-sm">Upload an image to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{media.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMedia(item.fileUrl)}
|
||||
className={`relative aspect-square rounded-btn overflow-hidden border-2 transition-all ${
|
||||
selectedMedia === item.fileUrl
|
||||
? 'border-primary-yellow ring-2 ring-primary-yellow/30'
|
||||
: 'border-transparent hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.fileUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{selectedMedia === item.fileUrl && (
|
||||
<div className="absolute inset-0 bg-primary-yellow/20 flex items-center justify-center">
|
||||
<div className="bg-primary-yellow rounded-full p-1">
|
||||
<CheckIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
{activeTab === 'library' && (
|
||||
<div className="flex justify-end gap-3 p-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowModal(false); setSelectedMedia(null); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSelectFromLibrary}
|
||||
disabled={!selectedMedia}
|
||||
>
|
||||
Select Image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/PlausibleAnalytics.tsx
Normal file
28
frontend/src/components/PlausibleAnalytics.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function PlausibleAnalytics() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get Plausible configuration from environment variables
|
||||
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL;
|
||||
const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
|
||||
|
||||
// Don't render on admin pages or if configuration is missing
|
||||
const isAdminPage = pathname?.startsWith('/admin');
|
||||
|
||||
if (isAdminPage || !plausibleUrl || !plausibleDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Script
|
||||
defer
|
||||
data-domain={plausibleDomain}
|
||||
src={`${plausibleUrl}/js/script.js`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export default function Footer() {
|
||||
<p className="mt-3 text-gray-600 max-w-md">
|
||||
{t('footer.tagline')}
|
||||
</p>
|
||||
{/* Local SEO text */}
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Language Exchange Events in Asunción, Paraguay
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
|
||||
@@ -270,6 +270,14 @@ export const paymentOptionsApi = {
|
||||
|
||||
// Media API
|
||||
export const mediaApi = {
|
||||
getAll: (relatedType?: string, relatedId?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (relatedType) params.set('relatedType', relatedType);
|
||||
if (relatedId) params.set('relatedId', relatedId);
|
||||
const query = params.toString();
|
||||
return fetchApi<{ media: Media[] }>(`/api/media${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
upload: async (file: File, relatedId?: string, relatedType?: string) => {
|
||||
const token = typeof window !== 'undefined'
|
||||
? localStorage.getItem('spanglish-token')
|
||||
@@ -488,6 +496,8 @@ export interface PaymentOptionsConfig {
|
||||
cashEnabled: boolean;
|
||||
cashInstructions?: string | null;
|
||||
cashInstructionsEs?: string | null;
|
||||
// Booking settings
|
||||
allowDuplicateBookings?: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user