Add full SEO optimization for Spanglish social and language events
- Add comprehensive metadata to root layout with Open Graph, Twitter cards - Create dynamic sitemap.ts for all pages and events - Create robots.ts with proper allow/disallow rules - Add JSON-LD Event structured data to event detail pages - Add page-specific metadata to events, community, contact, FAQ pages - Add FAQ structured data schema - Update footer with local SEO text for Asunción, Paraguay - Add web manifest for mobile SEO - Create 404 page with proper noindex - Optimize image alt text and add lazy loading - Add NEXT_PUBLIC_SITE_URL env variable - Add about/ folder to gitignore
This commit is contained in:
@@ -11,7 +11,8 @@ const mediaRouter = new Hono();
|
||||
|
||||
const UPLOAD_DIR = './uploads';
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_SIZE =
|
||||
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
||||
|
||||
// Ensure upload directory exists
|
||||
async function ensureUploadDir() {
|
||||
@@ -37,7 +38,8 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return c.json({ error: 'File too large. Maximum size: 5MB' }, 400);
|
||||
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
||||
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
||||
}
|
||||
|
||||
await ensureUploadDir();
|
||||
|
||||
@@ -26,6 +26,8 @@ const updatePaymentOptionsSchema = z.object({
|
||||
cashEnabled: z.boolean().optional(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema for event-level overrides
|
||||
@@ -75,6 +77,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, tickets, events, users, payments } from '../db/index.js';
|
||||
import { db, tickets, events, users, payments, paymentOptions } from '../db/index.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
||||
@@ -13,9 +13,9 @@ const ticketsRouter = new Hono();
|
||||
const createTicketSchema = z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(2),
|
||||
lastName: z.string().min(2),
|
||||
lastName: z.string().min(2).optional().or(z.literal('')),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(6, 'Phone number is required'),
|
||||
phone: z.string().min(6).optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
|
||||
@@ -76,7 +76,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
const now = getNow();
|
||||
|
||||
const fullName = `${data.firstName} ${data.lastName}`.trim();
|
||||
const fullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
: data.firstName;
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
@@ -94,20 +96,29 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for duplicate booking
|
||||
const existingTicket = await (db as any)
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const globalOptions = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'You have already booked this event' }, 400);
|
||||
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
if (!allowDuplicateBookings) {
|
||||
const existingTicket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'You have already booked this event' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
@@ -122,9 +133,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: ticketStatus,
|
||||
@@ -151,6 +162,20 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
|
||||
// Send payment instructions email for manual payment methods (TPago, Bank Transfer)
|
||||
if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) {
|
||||
// Send asynchronously - don't block the response
|
||||
emailService.sendPaymentInstructions(ticketId).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending payment instructions email:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// If Lightning payment, create LNbits invoice
|
||||
let lnbitsInvoice = null;
|
||||
if (data.paymentMethod === 'lightning' && event.price > 0) {
|
||||
@@ -389,6 +414,23 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
|
||||
}
|
||||
|
||||
// Handle idempotency - if already marked as sent or paid, return success with current state
|
||||
if (payment.status === 'pending_approval') {
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment was already marked as sent. Waiting for admin approval.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (payment.status === 'paid') {
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment has already been confirmed.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow if currently pending
|
||||
if (payment.status !== 'pending') {
|
||||
return c.json({ error: 'Payment has already been processed' }, 400);
|
||||
|
||||
Reference in New Issue
Block a user